@pippit-dev/cli 1.0.25 → 1.0.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +23 -1
  2. package/checksums.txt +6 -6
  3. package/cmd/generate_image/generate_image.go +1 -0
  4. package/cmd/generate_video/generate_video.go +7 -6
  5. package/cmd/generate_video_test.go +57 -55
  6. package/cmd/model.go +74 -0
  7. package/cmd/model_test.go +81 -0
  8. package/cmd/root.go +1 -0
  9. package/cmd/short_drama/short_drama.go +1 -0
  10. package/cmd/source_test.go +179 -0
  11. package/cmd/submit_run.go +3 -0
  12. package/cmd/update/update.go +1 -1
  13. package/cmd/video_tool/video_tool.go +2 -0
  14. package/dist/checksums.txt +6 -6
  15. package/internal/common/submit_run.go +38 -1
  16. package/internal/config/config.go +13 -10
  17. package/internal/config/config_test.go +3 -0
  18. package/internal/generate_image/generate_image.go +2 -1
  19. package/internal/generate_video/generate_video.go +12 -18
  20. package/internal/generate_video/generate_video_test.go +126 -0
  21. package/internal/models/describe.go +281 -0
  22. package/internal/models/describe_test.go +199 -0
  23. package/internal/models/models.go +220 -0
  24. package/internal/models/models_test.go +200 -0
  25. package/internal/short_drama/submit_run.go +2 -0
  26. package/internal/video_tool/video_tool.go +4 -2
  27. package/package.json +1 -1
  28. package/skills/short-drama/SKILL.md +4 -0
  29. package/skills/xyq-nest-skill/SKILL.md +10 -0
  30. package/skills/xyq-nest-skill/commands/erase-video-subtitle.md +2 -0
  31. package/skills/xyq-nest-skill/commands/generate-image.md +2 -0
  32. package/skills/xyq-nest-skill/commands/generate-video.md +34 -10
  33. package/skills/xyq-nest-skill/commands/model.md +59 -0
  34. package/skills/xyq-nest-skill/commands/video-super-resolution.md +2 -0
  35. package/skills/xyq-nest-skill/scripts/ensure-cli.js +2 -2
  36. package/skills/xyq-nest-skill/scripts/install.md +1 -1
@@ -0,0 +1,199 @@
1
+ package models
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "reflect"
7
+ "testing"
8
+ )
9
+
10
+ func description(t *testing.T, raw string) map[string]json.RawMessage {
11
+ t.Helper()
12
+ out, err := describeModel(json.RawMessage(raw))
13
+ if err != nil {
14
+ t.Fatal(err)
15
+ }
16
+ var fields map[string]json.RawMessage
17
+ if err := json.Unmarshal(out, &fields); err != nil {
18
+ t.Fatal(err)
19
+ }
20
+ return fields
21
+ }
22
+
23
+ func TestDescriptionCLIParametersAndRawPreservation(t *testing.T) {
24
+ raw := `{"key":"MiniMax-H3","supported_ratio_list":[0,2,13,3,4,5,6],"default_ratio":3,"config_key":"internal",
25
+ "future_field":9007199254740993,"audio_total_limit":0,"max_image_size":31457280,"min_video_duration":2000,
26
+ "supported_duration_list":[{"value":999}],"default_duration_value":999,
27
+ "parameter_config":{"dimensions":[
28
+ {"key":"duration","default_value":"10","range_config":{"min_value":4,"max_value":15,"step":1}},
29
+ {"key":"resolution","default_value":" 768P ","option_list":[{"value":"768p"},{"value":"2k"},{"value":"4k","disabled":true}]},
30
+ {"key":"seed","default_value":"random"}],"need_available_combinations":true}}
31
+ `
32
+ out := description(t, raw)
33
+ for _, key := range []string{"config_key", "supported_ratio_list", "default_ratio", "supported_duration_list", "default_duration_value", "audio_total_limit", "max_image_size"} {
34
+ if _, ok := out[key]; ok {
35
+ t.Fatalf("unconverted field %s", key)
36
+ }
37
+ }
38
+ var ratio struct {
39
+ Options []string
40
+ Default string
41
+ }
42
+ if err := json.Unmarshal(out["ratio"], &ratio); err != nil {
43
+ t.Fatal(err)
44
+ }
45
+ if !reflect.DeepEqual(ratio.Options, []string{"adaptive", "16:9", "21:9", "9:16", "4:3", "3:4", "1:1"}) || ratio.Default != "9:16" {
46
+ t.Fatalf("ratio=%+v", ratio)
47
+ }
48
+ var resolution struct {
49
+ Options []string
50
+ Default string
51
+ }
52
+ if err := json.Unmarshal(out["resolution"], &resolution); err != nil {
53
+ t.Fatal(err)
54
+ }
55
+ if !reflect.DeepEqual(resolution.Options, []string{"768p", "2k"}) || resolution.Default != "768p" {
56
+ t.Fatalf("resolution=%+v", resolution)
57
+ }
58
+ var duration struct {
59
+ Min, Max, Step, Default int
60
+ Unit string
61
+ }
62
+ if err := json.Unmarshal(out["duration"], &duration); err != nil {
63
+ t.Fatal(err)
64
+ }
65
+ if duration.Min != 4 || duration.Max != 15 || duration.Step != 1 || duration.Default != 10 || duration.Unit != "seconds" {
66
+ t.Fatalf("duration=%+v", duration)
67
+ }
68
+ if string(out["future_field"]) != "9007199254740993" {
69
+ t.Fatal("integer precision lost")
70
+ }
71
+ var limits map[string]json.RawMessage
72
+ if err := json.Unmarshal(out["material_limits"], &limits); err != nil {
73
+ t.Fatal(err)
74
+ }
75
+ if string(limits["audio_total_limit"]) != "0" || string(limits["max_image_size_bytes"]) != "31457280" || string(limits["min_video_duration_ms"]) != "2000" {
76
+ t.Fatalf("limits=%s", out["material_limits"])
77
+ }
78
+ if _, ok := limits["video_total_limit"]; ok {
79
+ t.Fatal("absent limit must stay absent")
80
+ }
81
+ if !bytes.Contains(out["parameter_config"], []byte("seed")) || !bytes.Contains(out["parameter_config"], []byte("need_available_combinations")) {
82
+ t.Fatal("unrelated configuration lost")
83
+ }
84
+ if _, ok := out["warnings"]; ok {
85
+ t.Fatalf("valid dimensions must override legacy durations: %s", out["warnings"])
86
+ }
87
+ if !bytes.Contains(out["notes"], []byte("纯文生视频")) {
88
+ t.Fatal("missing MiniMax adaptive restriction")
89
+ }
90
+ }
91
+
92
+ func TestDescriptionUnknownRatioAndDefault(t *testing.T) {
93
+ for _, value := range []int{1, 999} {
94
+ raw, _ := json.Marshal(map[string]any{"supported_ratio_list": []int{value, 2, 2}, "default_ratio": value})
95
+ out := description(t, string(raw))
96
+ var ratio struct {
97
+ Options []string
98
+ Default *string
99
+ }
100
+ if err := json.Unmarshal(out["ratio"], &ratio); err != nil {
101
+ t.Fatal(err)
102
+ }
103
+ if !reflect.DeepEqual(ratio.Options, []string{"16:9"}) || ratio.Default != nil {
104
+ t.Fatalf("invented usable value: %+v", ratio)
105
+ }
106
+ if _, exists := out["warnings"]; exists {
107
+ t.Fatal("unknown and custom enums should be silently skipped")
108
+ }
109
+ }
110
+ out := description(t, `{"supported_ratio_list":[0,7,8,9,10,11,12],"default_ratio":0}`)
111
+ var ratio struct {
112
+ Options []string
113
+ Default string
114
+ }
115
+ if err := json.Unmarshal(out["ratio"], &ratio); err != nil {
116
+ t.Fatal(err)
117
+ }
118
+ if ratio.Default != "adaptive" || !reflect.DeepEqual(ratio.Options, []string{"adaptive", "2:1", "2.35:1", "1.85:1", "1.125:2.436", "3:2", "2:3"}) {
119
+ t.Fatalf("ratio=%+v", ratio)
120
+ }
121
+ }
122
+
123
+ func TestDescriptionDimensionFailuresAndOptions(t *testing.T) {
124
+ for _, dimension := range []string{
125
+ `{"key":"resolution","default_value":"2k","option_list":[{"value":"768p"},{"value":"2k","disabled":true}]}`,
126
+ `{"key":"resolution","default_value":"","option_list":[]}`,
127
+ `{"key":"duration","default_value":"7","range_config":{"min_value":4,"max_value":10,"step":2}}`,
128
+ `{"key":"duration","range_config":{"min_value":10,"max_value":4}}`,
129
+ `{"key":"duration","option_list":[{"value":"auto"}]}`,
130
+ } {
131
+ out := description(t, `{"parameter_config":{"dimensions":[`+dimension+`]}}`)
132
+ if _, ok := out["warnings"]; !ok {
133
+ t.Fatalf("missing warning: %s", dimension)
134
+ }
135
+ for _, key := range []string{"resolution", "duration"} {
136
+ var value map[string]json.RawMessage
137
+ if len(out[key]) == 0 {
138
+ continue
139
+ }
140
+ if err := json.Unmarshal(out[key], &value); err != nil {
141
+ t.Fatal(err)
142
+ }
143
+ if _, ok := value["default"]; ok {
144
+ t.Fatalf("invalid default exposed: %s", out[key])
145
+ }
146
+ }
147
+ }
148
+ out := description(t, `{"parameter_config":{"dimensions":[{"key":"duration","default_value":"10","option_list":[{"value":"5"},{"value":"10"},{"value":"15","disabled":true}]}]}}`)
149
+ var duration struct {
150
+ Options []int
151
+ Default int
152
+ Min *int
153
+ }
154
+ if err := json.Unmarshal(out["duration"], &duration); err != nil {
155
+ t.Fatal(err)
156
+ }
157
+ if !reflect.DeepEqual(duration.Options, []int{5, 10}) || duration.Default != 10 || duration.Min != nil {
158
+ t.Fatalf("duration=%+v", duration)
159
+ }
160
+ }
161
+
162
+ func TestDescriptionCreationModesAndCacheNotMutated(t *testing.T) {
163
+ raw := json.RawMessage(`{"key":"MiniMax-H3-Max","kind":"video","supported_ratio_list":[0,2],"default_ratio":2,"creation_mode_config":{"schema_version":1,"default_mode":"text_to_video","modes":[{"key":"text_to_video","enabled":true,"ratio_policy":{"value_mode":"inherit"}},{"key":"first_last_frame","enabled":true,"ratio_policy":{"value_mode":"smart"},"validation_policy":{"required_video_count":0,"forbidden_input_types":["video","audio"]}}]}}`)
164
+ before := append([]byte(nil), raw...)
165
+ var catalog Catalog
166
+ if err := json.Unmarshal([]byte(`{"config":{"models":[]}}`), &catalog); err != nil {
167
+ t.Fatal(err)
168
+ }
169
+ catalog.Config.Models = append(catalog.Config.Models, raw)
170
+ result, err := catalog.Describe("MiniMax-H3-Max")
171
+ if err != nil {
172
+ t.Fatal(err)
173
+ }
174
+ var out struct {
175
+ Modes []struct {
176
+ GenerateType int `json:"generate_type"`
177
+ Ratio struct {
178
+ Options []string
179
+ Default string
180
+ }
181
+ Validation struct {
182
+ Required *int `json:"required_video_count"`
183
+ Forbidden []string `json:"forbidden_input_types"`
184
+ } `json:"validation_policy"`
185
+ } `json:"creation_modes"`
186
+ }
187
+ if err := json.Unmarshal(result, &out); err != nil {
188
+ t.Fatal(err)
189
+ }
190
+ if len(out.Modes) != 2 || out.Modes[0].Ratio.Default != "16:9" || !reflect.DeepEqual(out.Modes[0].Ratio.Options, []string{"16:9"}) || out.Modes[1].GenerateType != 1 || out.Modes[1].Ratio.Default != "adaptive" || !reflect.DeepEqual(out.Modes[1].Ratio.Options, []string{"adaptive"}) {
191
+ t.Fatalf("modes=%+v", out.Modes)
192
+ }
193
+ if out.Modes[1].Validation.Required == nil || *out.Modes[1].Validation.Required != 0 || !reflect.DeepEqual(out.Modes[1].Validation.Forbidden, []string{"video", "audio"}) {
194
+ t.Fatal("mode requirements lost")
195
+ }
196
+ if !bytes.Equal(before, catalog.Config.Models[0]) {
197
+ t.Fatal("description mutated cached raw config")
198
+ }
199
+ }
@@ -0,0 +1,220 @@
1
+ package models
2
+
3
+ import (
4
+ "context"
5
+ "crypto/sha256"
6
+ "encoding/json"
7
+ "fmt"
8
+ "os"
9
+ "path/filepath"
10
+ "strings"
11
+ "time"
12
+
13
+ "github.com/Pippit-dev/pippit-cli/internal/common"
14
+ "github.com/Pippit-dev/pippit-cli/internal/config"
15
+ "github.com/Pippit-dev/pippit-cli/internal/version"
16
+ )
17
+
18
+ const (
19
+ Scene = "web_turbo_video_generator"
20
+ CacheTTL = 5 * time.Minute
21
+ )
22
+
23
+ // Catalog preserves the server's model configuration, including unknown fields
24
+ // and integer enums. It is never used as a local generation allowlist.
25
+ type Catalog struct {
26
+ Scene string `json:"scene"`
27
+ ConfigKey string `json:"config_key"`
28
+ Config *struct {
29
+ Models []json.RawMessage `json:"models"`
30
+ } `json:"config"`
31
+ }
32
+
33
+ type Summary struct {
34
+ Key string `json:"key"`
35
+ Name string `json:"name"`
36
+ Kind string `json:"kind"`
37
+ IsDefault bool `json:"is_default"`
38
+ }
39
+
40
+ type Result struct {
41
+ Catalog *Catalog
42
+ Cached bool
43
+ FetchedAt time.Time
44
+ Warning string
45
+ }
46
+
47
+ type cacheEntry struct {
48
+ FetchedAt time.Time `json:"fetched_at"`
49
+ Catalog *Catalog `json:"catalog"`
50
+ }
51
+
52
+ type Service struct {
53
+ runner *common.Runner
54
+ cacheDir string
55
+ now func() time.Time
56
+ }
57
+
58
+ func NewService(runner *common.Runner) *Service {
59
+ dir, err := os.UserCacheDir()
60
+ if err == nil {
61
+ dir = filepath.Join(dir, "pippit-cli", "models")
62
+ }
63
+ return &Service{runner: runner, cacheDir: dir, now: time.Now}
64
+ }
65
+
66
+ func (s *Service) Get(ctx context.Context, refresh bool) (*Result, error) {
67
+ if err := ctx.Err(); err != nil {
68
+ return nil, err
69
+ }
70
+ if s.runner == nil || s.runner.Config == nil || s.runner.Client == nil || s.runner.Auth == nil {
71
+ return nil, fmt.Errorf("模型查询运行器未初始化")
72
+ }
73
+ // Resolve credentials before reading cache: logout/expiry must not expose a
74
+ // previous account's model list. Only a hash is used in the cache filename.
75
+ accessKey, err := s.runner.Auth.ResolveAccessKey(ctx)
76
+ if err != nil || strings.TrimSpace(accessKey) == "" {
77
+ return nil, fmt.Errorf("模型查询需要有效登录,请执行 pippit-tool-cli login 或检查 XYQ_ACCESS_KEY 后重试")
78
+ }
79
+ path := config.GetAvailableModelListPath
80
+ if s.runner.Config.Paths != nil && s.runner.Config.Paths.GetAvailableModelList != "" {
81
+ path = s.runner.Config.Paths.GetAvailableModelList
82
+ }
83
+ cachePath := s.cachePath(accessKey, path)
84
+ if !refresh && cachePath != "" {
85
+ if entry := s.readCache(cachePath); entry != nil {
86
+ return &Result{Catalog: entry.Catalog, Cached: true, FetchedAt: entry.FetchedAt}, nil
87
+ }
88
+ }
89
+ // A failed refresh must not make the next retry reuse an older fresh entry.
90
+ if cachePath != "" {
91
+ _ = os.Remove(cachePath)
92
+ }
93
+ queryCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
94
+ defer cancel()
95
+ var resp struct {
96
+ Ret string `json:"ret"`
97
+ Errmsg string `json:"errmsg"`
98
+ LogID string `json:"log_id"`
99
+ Data *Catalog `json:"data"`
100
+ }
101
+ err = s.runner.Client.SendRequest(queryCtx, path, struct {
102
+ Scene string `json:"scene"`
103
+ SourceModelKey string `json:"source_model_key"`
104
+ }{Scene: Scene}, &resp)
105
+ if err != nil {
106
+ return nil, fmt.Errorf("模型查询失败,请稍后重试(可加 --refresh): %w", err)
107
+ }
108
+ if resp.Ret != "0" {
109
+ return nil, common.NewLogIDError(fmt.Sprintf("模型查询失败,请稍后重试(可加 --refresh): ret=%s errmsg=%s", resp.Ret, resp.Errmsg), resp.LogID)
110
+ }
111
+ if err := validateCatalog(resp.Data); err != nil {
112
+ return nil, common.NewLogIDError("模型配置无效,请稍后重试(可加 --refresh): "+err.Error(), resp.LogID)
113
+ }
114
+ currentKey, err := s.runner.Auth.ResolveAccessKey(ctx)
115
+ if err != nil || currentKey != accessKey {
116
+ return nil, fmt.Errorf("查询期间登录凭证已变化,请重试模型查询")
117
+ }
118
+ result := &Result{Catalog: resp.Data, FetchedAt: s.now().UTC()}
119
+ if cachePath == "" || s.writeCache(cachePath, result) != nil {
120
+ result.Warning = "模型查询成功,但本地缓存写入失败;下次查询将重新请求服务端"
121
+ }
122
+ return result, nil
123
+ }
124
+
125
+ func (s *Service) cachePath(accessKey, path string) string {
126
+ if s.cacheDir == "" {
127
+ return ""
128
+ }
129
+ scope, _ := json.Marshal([]string{
130
+ "v1", strings.TrimRight(s.runner.Config.BaseURL, "/"), path, Scene,
131
+ version.Current(), accessKey,
132
+ })
133
+ return filepath.Join(s.cacheDir, fmt.Sprintf("%x.json", sha256.Sum256(scope)))
134
+ }
135
+
136
+ func (s *Service) readCache(path string) *cacheEntry {
137
+ raw, err := os.ReadFile(path)
138
+ if err != nil {
139
+ return nil
140
+ }
141
+ var entry cacheEntry
142
+ if json.Unmarshal(raw, &entry) != nil || validateCatalog(entry.Catalog) != nil {
143
+ return nil
144
+ }
145
+ age := s.now().Sub(entry.FetchedAt)
146
+ if age < 0 || age >= CacheTTL {
147
+ return nil
148
+ }
149
+ return &entry
150
+ }
151
+
152
+ func (s *Service) writeCache(path string, result *Result) error {
153
+ raw, err := json.Marshal(cacheEntry{FetchedAt: result.FetchedAt, Catalog: result.Catalog})
154
+ if err != nil {
155
+ return err
156
+ }
157
+ if err := os.MkdirAll(s.cacheDir, 0700); err != nil {
158
+ return err
159
+ }
160
+ f, err := os.CreateTemp(s.cacheDir, ".models-*.tmp")
161
+ if err != nil {
162
+ return err
163
+ }
164
+ defer os.Remove(f.Name())
165
+ _, writeErr := f.Write(raw)
166
+ closeErr := f.Close()
167
+ if writeErr != nil {
168
+ return writeErr
169
+ }
170
+ if closeErr != nil {
171
+ return closeErr
172
+ }
173
+ return os.Rename(f.Name(), path)
174
+ }
175
+
176
+ func validateCatalog(catalog *Catalog) error {
177
+ if catalog == nil || catalog.Config == nil || catalog.Scene != Scene || catalog.ConfigKey == "" {
178
+ return fmt.Errorf("缺少配置或场景不匹配")
179
+ }
180
+ seen := make(map[string]bool)
181
+ for _, raw := range catalog.Config.Models {
182
+ var model Summary
183
+ if json.Unmarshal(raw, &model) != nil || strings.TrimSpace(model.Key) == "" || model.Kind != "video" {
184
+ return fmt.Errorf("模型条目缺少有效 key 或 kind")
185
+ }
186
+ if seen[model.Key] {
187
+ return fmt.Errorf("模型 key 重复: %s", model.Key)
188
+ }
189
+ seen[model.Key] = true
190
+ }
191
+ return nil
192
+ }
193
+
194
+ func (c *Catalog) Search(query string) []Summary {
195
+ query = strings.TrimSpace(query)
196
+ matches := make([]Summary, 0)
197
+ for _, raw := range c.Config.Models {
198
+ var model Summary
199
+ _ = json.Unmarshal(raw, &model) // validated at the network/cache boundary
200
+ if model.Key == query {
201
+ return []Summary{model}
202
+ }
203
+ if query == "" || strings.Contains(strings.ToLower(model.Name), strings.ToLower(query)) || strings.Contains(strings.ToLower(model.Key), strings.ToLower(query)) {
204
+ matches = append(matches, model)
205
+ }
206
+ }
207
+ return matches
208
+ }
209
+
210
+ func (c *Catalog) Describe(key string) (json.RawMessage, error) {
211
+ key = strings.TrimSpace(key)
212
+ for _, raw := range c.Config.Models {
213
+ var model Summary
214
+ _ = json.Unmarshal(raw, &model)
215
+ if model.Key == key {
216
+ return describeModel(raw)
217
+ }
218
+ }
219
+ return nil, fmt.Errorf("未找到可用模型 %q;请执行 model list --refresh 查看当前模型", key)
220
+ }
@@ -0,0 +1,200 @@
1
+ package models
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "net/http"
8
+ "net/http/httptest"
9
+ "os"
10
+ "path/filepath"
11
+ "strings"
12
+ "testing"
13
+ "time"
14
+
15
+ "github.com/Pippit-dev/pippit-cli/internal/auth"
16
+ "github.com/Pippit-dev/pippit-cli/internal/common"
17
+ "github.com/Pippit-dev/pippit-cli/internal/config"
18
+ )
19
+
20
+ const validResponse = `{"ret":"0","data":{"scene":"web_turbo_video_generator","config_key":"key1","config":{"models":[{"key":"new-model","kind":"video","name":"新模型","is_default":true,"supported_ratio_list":[0,1],"audio_total_limit":0,"future_field":9007199254740993}]}}}`
21
+
22
+ func newTestService(t *testing.T, handler http.HandlerFunc) *Service {
23
+ t.Helper()
24
+ server := httptest.NewServer(handler)
25
+ t.Cleanup(server.Close)
26
+ cfg := config.Load()
27
+ cfg.BaseURL, cfg.AccessKey = server.URL, "private-test-key"
28
+ runner := common.NewRunner(cfg, nil)
29
+ runner.Auth = auth.NewManager(cfg)
30
+ runner.Client = common.NewHTTPClient(server.URL, time.Second, common.NewAccessKeyContextProviderAuthorizer(runner.Auth.ResolveAccessKey))
31
+ s := NewService(runner)
32
+ s.cacheDir = t.TempDir()
33
+ return s
34
+ }
35
+
36
+ type unavailableAuth struct{ common.AuthManager }
37
+
38
+ func (unavailableAuth) ResolveAccessKey(context.Context) (string, error) {
39
+ return "", errors.New("expired credential")
40
+ }
41
+
42
+ func TestMissingCredentialsCannotReadFreshCache(t *testing.T) {
43
+ requests := 0
44
+ s := newTestService(t, func(w http.ResponseWriter, _ *http.Request) {
45
+ requests++
46
+ _, _ = w.Write([]byte(validResponse))
47
+ })
48
+ if _, err := s.Get(context.Background(), false); err != nil {
49
+ t.Fatal(err)
50
+ }
51
+ s.runner.Auth = unavailableAuth{}
52
+ result, err := s.Get(context.Background(), false)
53
+ if err == nil || result != nil || requests != 1 || !strings.Contains(err.Error(), "login") {
54
+ t.Fatalf("must require login before cache: result=%+v err=%v requests=%d", result, err, requests)
55
+ }
56
+ }
57
+
58
+ func TestCacheTTLRefreshAndIsolation(t *testing.T) {
59
+ requests := 0
60
+ s := newTestService(t, func(w http.ResponseWriter, r *http.Request) {
61
+ requests++
62
+ if r.Method != "POST" || r.URL.Path != config.GetAvailableModelListPath || !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
63
+ t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
64
+ }
65
+ var body map[string]string
66
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body["scene"] != Scene || body["source_model_key"] != "" {
67
+ t.Errorf("unexpected body: %v, err=%v", body, err)
68
+ }
69
+ _, _ = w.Write([]byte(validResponse))
70
+ })
71
+ now := time.Date(2026, 9, 20, 12, 0, 0, 0, time.UTC)
72
+ s.now = func() time.Time { return now }
73
+ check := func(refresh, cached bool, count int) *Result {
74
+ t.Helper()
75
+ result, err := s.Get(context.Background(), refresh)
76
+ if err != nil || result.Cached != cached || requests != count {
77
+ t.Fatalf("Get: result=%+v err=%v requests=%d, want cached=%v requests=%d", result, err, requests, cached, count)
78
+ }
79
+ return result
80
+ }
81
+ first := check(false, false, 1)
82
+ now = now.Add(CacheTTL - time.Second)
83
+ cached := check(false, true, 1)
84
+ if !cached.FetchedAt.Equal(first.FetchedAt) {
85
+ t.Fatal("cache hit must not renew TTL")
86
+ }
87
+ now = now.Add(time.Second)
88
+ check(false, false, 2) // expires exactly at five minutes
89
+ check(true, false, 3)
90
+ s.runner.Config.AccessKey = "second-account"
91
+ check(false, false, 4)
92
+ oldPath := s.cachePath("second-account", config.GetAvailableModelListPath)
93
+ s.runner.Config.BaseURL += "/other-api"
94
+ if s.cachePath("second-account", config.GetAvailableModelListPath) == oldPath {
95
+ t.Fatal("API base URLs must not share cache")
96
+ }
97
+ files, _ := os.ReadDir(s.cacheDir)
98
+ for _, file := range files {
99
+ raw, _ := os.ReadFile(filepath.Join(s.cacheDir, file.Name()))
100
+ if strings.Contains(string(raw)+file.Name(), "private-test-key") || strings.Contains(string(raw)+file.Name(), "second-account") {
101
+ t.Fatal("cache must not persist raw credentials")
102
+ }
103
+ }
104
+ }
105
+
106
+ func TestFailedQueryDoesNotUseOrCacheStaleData(t *testing.T) {
107
+ response := validResponse
108
+ requests := 0
109
+ s := newTestService(t, func(w http.ResponseWriter, _ *http.Request) {
110
+ requests++
111
+ _, _ = w.Write([]byte(response))
112
+ })
113
+ if _, err := s.Get(context.Background(), false); err != nil {
114
+ t.Fatal(err)
115
+ }
116
+ for _, invalid := range []string{
117
+ `{"ret":"1001","errmsg":"unavailable","log_id":"log_123"}`,
118
+ `{"ret":"0","data":null}`,
119
+ `{"ret":"0","data":{"scene":"other","config_key":"key","config":{}}}`,
120
+ `not-json`,
121
+ } {
122
+ response = invalid
123
+ if result, err := s.Get(context.Background(), true); err == nil || result != nil || !strings.Contains(err.Error(), "重试") {
124
+ t.Fatalf("failed query must suggest retry: result=%+v err=%v", result, err)
125
+ }
126
+ before := requests
127
+ if _, err := s.Get(context.Background(), false); err == nil || requests != before+1 {
128
+ t.Fatal("retry must query the server, not reuse cached success or failure")
129
+ }
130
+ }
131
+ response = validResponse
132
+ if _, err := s.Get(context.Background(), false); err != nil {
133
+ t.Fatal(err)
134
+ }
135
+ cachePath := s.cachePath(s.runner.Config.AccessKey, config.GetAvailableModelListPath)
136
+ if err := os.WriteFile(cachePath, []byte("broken"), 0600); err != nil {
137
+ t.Fatal(err)
138
+ }
139
+ result, err := s.Get(context.Background(), false)
140
+ if err != nil || result.Cached {
141
+ t.Fatalf("corrupt cache should refresh: %+v %v", result, err)
142
+ }
143
+ // A timestamp in the future must not extend visibility indefinitely.
144
+ s.now = func() time.Time { return result.FetchedAt.Add(-time.Second) }
145
+ if s.readCache(cachePath) != nil {
146
+ t.Fatal("future-dated cache must be ignored")
147
+ }
148
+ }
149
+
150
+ func TestModelConfigurationPreservedAcrossCache(t *testing.T) {
151
+ s := newTestService(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(validResponse)) })
152
+ for i := 0; i < 2; i++ {
153
+ result, err := s.Get(context.Background(), false)
154
+ if err != nil {
155
+ t.Fatal(err)
156
+ }
157
+ raw, err := result.Catalog.Describe("new-model")
158
+ if err != nil || !strings.Contains(string(raw), "9007199254740993") || !strings.Contains(string(raw), `"audio_total_limit":0`) {
159
+ t.Fatalf("configuration lost values: %s %v", raw, err)
160
+ }
161
+ if len(result.Catalog.Search("新模")) != 1 || len(result.Catalog.Search("missing")) != 0 {
162
+ t.Fatal("unexpected search result")
163
+ }
164
+ if _, err := result.Catalog.Describe("missing"); err == nil {
165
+ t.Fatal("missing model must not invent a config")
166
+ }
167
+ }
168
+ }
169
+
170
+ func TestCacheWriteFailureStillReturnsServerResult(t *testing.T) {
171
+ s := newTestService(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(validResponse)) })
172
+ file := filepath.Join(t.TempDir(), "not-a-directory")
173
+ if err := os.WriteFile(file, []byte("x"), 0600); err != nil {
174
+ t.Fatal(err)
175
+ }
176
+ s.cacheDir = file
177
+ result, err := s.Get(context.Background(), false)
178
+ if err != nil || result.Cached || result.Warning == "" {
179
+ t.Fatalf("result=%+v err=%v", result, err)
180
+ }
181
+ }
182
+
183
+ func TestEmptyAndDuplicateModels(t *testing.T) {
184
+ for _, tc := range []struct {
185
+ data string
186
+ valid bool
187
+ }{
188
+ {`{"scene":"web_turbo_video_generator","config_key":"empty","config":{}}`, true},
189
+ {`{"scene":"web_turbo_video_generator","config_key":"dup","config":{"models":[{"key":"x","kind":"video"},{"key":"x","kind":"video"}]}}`, false},
190
+ {`{"scene":"web_turbo_video_generator","config_key":"null","config":{"models":[null]}}`, false},
191
+ } {
192
+ var catalog Catalog
193
+ if err := json.Unmarshal([]byte(tc.data), &catalog); err != nil {
194
+ t.Fatal(err)
195
+ }
196
+ if err := validateCatalog(&catalog); (err == nil) != tc.valid {
197
+ t.Fatalf("validateCatalog = %v", err)
198
+ }
199
+ }
200
+ }
@@ -9,6 +9,7 @@ import (
9
9
 
10
10
  // SubmitRunOptions is the stable command-facing request shape for short drama run submission.
11
11
  type SubmitRunOptions struct {
12
+ Source string `json:"platform,omitempty"`
12
13
  Message string `json:"message"`
13
14
  ThreadID string `json:"thread_id,omitempty"`
14
15
  AssetIDs []string `json:"asset_ids,omitempty"`
@@ -36,6 +37,7 @@ func SubmitRun(ctx context.Context, opts *SubmitRunOptions, runner *common.Runne
36
37
  body["asset_ids"] = opts.AssetIDs
37
38
  }
38
39
  body["agent_name"] = "pippit_nest_novel_agent"
40
+ body = common.WithSubmitRunSource(body, opts.Source)
39
41
 
40
42
  var resp common.SubmitRunResponse
41
43
  if err := runner.Client.SendRequest(ctx, common.SubmitRunPath(runner), body, &resp); err != nil {
@@ -17,6 +17,7 @@ const (
17
17
 
18
18
  // SuperResolutionOptions is the command-facing request shape for video-super-resolution.
19
19
  type SuperResolutionOptions struct {
20
+ Source string
20
21
  VideoPath string
21
22
  ToolVersion string
22
23
  OutputResolution string
@@ -24,6 +25,7 @@ type SuperResolutionOptions struct {
24
25
 
25
26
  // EraseSubtitleOptions is the command-facing request shape for erase-video-subtitle.
26
27
  type EraseSubtitleOptions struct {
28
+ Source string
27
29
  VideoPath string
28
30
  }
29
31
 
@@ -43,7 +45,7 @@ func RunSuperResolution(ctx context.Context, opts *SuperResolutionOptions, runne
43
45
  if err != nil {
44
46
  return nil, err
45
47
  }
46
- return common.SubmitRun(ctx, "video-super-resolution", buildSuperResolutionSubmitRunBody(opts, assetID), runner)
48
+ return common.SubmitRun(ctx, "video-super-resolution", common.WithSubmitRunSource(buildSuperResolutionSubmitRunBody(opts, assetID), opts.Source), runner)
47
49
  }
48
50
 
49
51
  // RunEraseSubtitle uploads one video and submits a subtitle-erasing run.
@@ -59,7 +61,7 @@ func RunEraseSubtitle(ctx context.Context, opts *EraseSubtitleOptions, runner *c
59
61
  if err != nil {
60
62
  return nil, err
61
63
  }
62
- return common.SubmitRun(ctx, "erase-video-subtitle", buildEraseSubtitleSubmitRunBody(assetID), runner)
64
+ return common.SubmitRun(ctx, "erase-video-subtitle", common.WithSubmitRunSource(buildEraseSubtitleSubmitRunBody(assetID), opts.Source), runner)
63
65
  }
64
66
 
65
67
  // ValidateSuperResolutionOptions validates only command shape; semantic enums remain service-owned.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pippit-dev/cli",
3
- "version": "1.0.25",
3
+ "version": "1.0.27",
4
4
  "description": "Pippit CLI",
5
5
  "bin": {
6
6
  "pippit-tool-cli": "scripts/run.js"
@@ -21,6 +21,10 @@ metadata:
21
21
 
22
22
  短剧场景面向剧情、人物、分集与画面化叙事创作,用户的原始需求通过 `--message` 发送给后端 Agent。后端 Agent 负责理解任务、编排流程和生成内容;用户侧 Agent 负责提交任务、并行查询进展与产物、主动下载重要资产并展示结果。
23
23
 
24
+ ## 宿主来源统计
25
+
26
+ 调用 `short-drama +submit-run` 时,宿主 Agent 根据可信的实际运行环境静默附加可选 `--source`:豆包办公为 `doubao_office`,WorkBuddy 为 `workbuddy`,Codex 为 `codex`;其它已知宿主使用真实、稳定的产品标识。不附带版本、会话 ID、用户信息或 prompt,不从用户创作内容猜测来源,不向用户询问或增加确认。来源不明时省略;仅用于统计,不影响实际工具效果,不改写 `--message`。上传、查询和下载命令不附加此参数。旧版 CLI 以 `--help` 为准,不支持时省略,不因统计字段阻塞任务或重复提交。
27
+
24
28
  ## 功能
25
29
 
26
30
  1. **提交短剧 Run 任务** - 创建新会话或向已有会话发送短剧创作需求。