@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.
- package/README.md +23 -1
- package/checksums.txt +6 -6
- package/cmd/generate_image/generate_image.go +1 -0
- package/cmd/generate_video/generate_video.go +7 -6
- package/cmd/generate_video_test.go +57 -55
- package/cmd/model.go +74 -0
- package/cmd/model_test.go +81 -0
- package/cmd/root.go +1 -0
- package/cmd/short_drama/short_drama.go +1 -0
- package/cmd/source_test.go +179 -0
- package/cmd/submit_run.go +3 -0
- package/cmd/update/update.go +1 -1
- package/cmd/video_tool/video_tool.go +2 -0
- package/dist/checksums.txt +6 -6
- package/internal/common/submit_run.go +38 -1
- package/internal/config/config.go +13 -10
- package/internal/config/config_test.go +3 -0
- package/internal/generate_image/generate_image.go +2 -1
- package/internal/generate_video/generate_video.go +12 -18
- package/internal/generate_video/generate_video_test.go +126 -0
- package/internal/models/describe.go +281 -0
- package/internal/models/describe_test.go +199 -0
- package/internal/models/models.go +220 -0
- package/internal/models/models_test.go +200 -0
- package/internal/short_drama/submit_run.go +2 -0
- package/internal/video_tool/video_tool.go +4 -2
- package/package.json +1 -1
- package/skills/short-drama/SKILL.md +4 -0
- package/skills/xyq-nest-skill/SKILL.md +10 -0
- package/skills/xyq-nest-skill/commands/erase-video-subtitle.md +2 -0
- package/skills/xyq-nest-skill/commands/generate-image.md +2 -0
- package/skills/xyq-nest-skill/commands/generate-video.md +34 -10
- package/skills/xyq-nest-skill/commands/model.md +59 -0
- package/skills/xyq-nest-skill/commands/video-super-resolution.md +2 -0
- package/skills/xyq-nest-skill/scripts/ensure-cli.js +2 -2
- 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
|
@@ -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 任务** - 创建新会话或向已有会话发送短剧创作需求。
|