{subscriptionTitle}
+{noSubscription}
+ )} +From baba2095191bd64514ebd575e098ad81a22c1f39 Mon Sep 17 00:00:00 2001 From: wtgoku <2687861+wtgoku@users.noreply.github.com> Date: Thu, 21 May 2026 16:53:07 +0800 Subject: [PATCH] Expose gateway billing flows from the synced server baseline The preserved local work adds PopiNewAPI billing checkout, gateway account binding, and matching web console/pricing surfaces on top of the current test-server baseline. During the merge, the start-end video helpers from the deployed baseline were kept alongside the actionGenerate prompt handling from the WIP. Constraint: Current usable baseline is 7054436, already deployed on the test server. Rejected: Commit the WIP before syncing the baseline | would have hidden conflicts with the deployed start-end-frame changes. Confidence: medium Scope-risk: broad Directive: Do not deploy this commit to the test server without rechecking gateway credentials and billing checkout behavior in that environment. Tested: go test ./... Tested: make build Tested: cd web && npm run build --- README.md | 66 ++ internal/server/billing_test.go | 91 ++ internal/server/gateway_billing_test.go | 303 +++++ internal/server/models_test.go | 16 +- internal/server/newapi.go | 1040 ++++++++++++++++- internal/server/newapi_image_test.go | 10 + internal/server/newapi_music_test.go | 81 ++ internal/server/newapi_speech_test.go | 61 + internal/server/repository.go | 1 + internal/server/routes_test.go | 50 +- internal/server/server.go | 766 ++++++++++++- internal/server/sqlite_repo.go | 74 +- internal/server/store.go | 4 + internal/server/types.go | 36 +- internal/server/video_test.go | 393 +++++++ web/app/[locale]/billing/page.tsx | 385 +++++++ web/app/[locale]/billing/success/page.tsx | 106 ++ web/app/[locale]/console/page.tsx | 340 +++++- web/app/[locale]/pricing/page.tsx | 149 ++- web/app/api/auth/gateway-bind/route.ts | 67 ++ web/app/api/billing/checkout/route.ts | 150 +++ web/app/globals.css | 121 +- web/components/billing-page-state.tsx | 35 + web/components/billing-purchase-actions.tsx | 208 ++++ web/components/billing-refresh-listener.tsx | 74 ++ web/components/gateway-bind-form.tsx | 127 +++ web/components/main-nav.tsx | 4 + web/components/site-chrome.tsx | 2 + web/components/vendor/qrcode-react.d.ts | 11 + web/components/vendor/qrcode-react.js | 1137 +++++++++++++++++++ web/lib/popiart-api.ts | 213 ++++ 31 files changed, 6035 insertions(+), 86 deletions(-) create mode 100644 internal/server/billing_test.go create mode 100644 internal/server/gateway_billing_test.go create mode 100644 internal/server/newapi_music_test.go create mode 100644 internal/server/newapi_speech_test.go create mode 100644 web/app/[locale]/billing/page.tsx create mode 100644 web/app/[locale]/billing/success/page.tsx create mode 100644 web/app/api/auth/gateway-bind/route.ts create mode 100644 web/app/api/billing/checkout/route.ts create mode 100644 web/components/billing-page-state.tsx create mode 100644 web/components/billing-purchase-actions.tsx create mode 100644 web/components/billing-refresh-listener.tsx create mode 100644 web/components/gateway-bind-form.tsx create mode 100644 web/components/vendor/qrcode-react.d.ts create mode 100644 web/components/vendor/qrcode-react.js diff --git a/README.md b/README.md index 06b4cc6..c678f2a 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,72 @@ go run ./cmd/popiart --endpoint http://127.0.0.1:8080/v1 media upload ./source.p go run ./cmd/popiart --endpoint http://127.0.0.1:8080/v1 artifacts upload ./source.png --role source ``` +### Jimeng action transfer + +`popiartServer` supports the `popiart video action-transfer` CLI path by routing +`jimeng*` / DreamActor video model requests through PopiNewAPI's unified video +gateway: + +```text +popiartcli + -> POST /v1/models/infer + -> popiartServer video.image2video job + -> PopiNewAPI POST /v1/video/generations + -> PopiNewAPI GET /v1/video/generations/{task_id} +``` + +CLI usage: + +```sh +popiart --endpoint http://127.0.0.1:8080/v1 video action-transfer \ + --image ./face.jpg \ + --video ./source-action.mp4 \ + --cut-result-first-second-switch \ + --wait \ + --output json \ + --quiet \ + --non-interactive +``` + +Request mapping: + +- `model_id=jimeng_dreamactor_m20_gen_video` +- `model_type=video` +- `input.images[0]` becomes gateway `images[0]` +- `input.videos[0]` becomes gateway `videos[0]` +- `metadata.action` defaults to `actionGenerate` for DreamActor +- `--cut-result-first-second-switch` maps to `metadata.cut_result_first_second_switch` +- Image data URLs are normalized to pure base64 before submission because Jimeng rejects the `data:image/*;base64,` prefix. + +The test server at `http://101.42.99.35:18080/v1` has been verified with a 5 second action-transfer preview returning an MP4 artifact. + +### Seedance / Doubao video + +`popiartServer` also supports Seedance / Doubao video models through the same +`video.image2video` job lane, but submits them to PopiNewAPI's unified JSON +video generations endpoint instead of the OpenAI-style `/v1/videos` path. + +CLI usage: + +```sh +popiart --endpoint http://127.0.0.1:8080/v1 video seedance \ + --prompt "keep the motion style consistent" \ + --video https://example.com/ref.mp4 \ + --ratio 16:9 \ + --return-last-frame \ + --wait \ + --output json \ + --quiet \ + --non-interactive +``` + +Request mapping: + +- default model: `doubao-seedance-2-0-260128` +- `images[]`, `videos[]`, `audios[]` pass through to gateway JSON input +- `size` and `duration` stay at top level +- `action`, `frames`, `ratio`, `return_last_frame`, `generate_audio`, `service_tier`, and related options map into `metadata` + Optional skillhub source: ```sh diff --git a/internal/server/billing_test.go b/internal/server/billing_test.go new file mode 100644 index 0000000..0dc814e --- /dev/null +++ b/internal/server/billing_test.go @@ -0,0 +1,91 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" +) + +func TestHandleBillingCatalogReturnsSeededPlans(t *testing.T) { + server, err := NewWithConfig(Config{ + SQLitePath: filepath.Join(t.TempDir(), "popiart.db"), + SkillhubDir: makeEmptySkillhub(t), + SessionSecret: "test-secret", + }) + if err != nil { + t.Fatalf("NewWithConfig: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/v1/billing/catalog", nil) + rec := httptest.NewRecorder() + server.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp struct { + OK bool `json:"ok"` + Data struct { + Plans []billingPlan `json:"plans"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if !resp.OK { + t.Fatalf("expected ok response, got %s", rec.Body.String()) + } + if len(resp.Data.Plans) != 2 { + t.Fatalf("expected 2 plans, got %d", len(resp.Data.Plans)) + } + if resp.Data.Plans[0].ProductType != "subscription" { + t.Fatalf("expected first plan to be subscription, got %#v", resp.Data.Plans[0]) + } +} + +func TestHandleBillingSubscriptionReturnsUnbound(t *testing.T) { + server, err := NewWithConfig(Config{ + SQLitePath: filepath.Join(t.TempDir(), "popiart.db"), + SkillhubDir: makeEmptySkillhub(t), + SessionSecret: "test-secret", + }) + if err != nil { + t.Fatalf("NewWithConfig: %v", err) + } + + sessionToken, _, ok, err := server.store.createSession("sk-billing-user") + if err != nil { + t.Fatalf("createSession: %v", err) + } + if !ok { + t.Fatal("expected session creation to succeed") + } + + req := httptest.NewRequest(http.MethodGet, "/v1/billing/subscription", nil) + req.Header.Set("Authorization", "Bearer "+sessionToken) + rec := httptest.NewRecorder() + server.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusPreconditionFailed { + t.Fatalf("expected 412, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp struct { + OK bool `json:"ok"` + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode error response: %v", err) + } + if resp.OK { + t.Fatalf("expected error response, got %s", rec.Body.String()) + } + if resp.Error.Code != "BILLING_UNBOUND" { + t.Fatalf("expected BILLING_UNBOUND, got %q", resp.Error.Code) + } +} diff --git a/internal/server/gateway_billing_test.go b/internal/server/gateway_billing_test.go new file mode 100644 index 0000000..7513d72 --- /dev/null +++ b/internal/server/gateway_billing_test.go @@ -0,0 +1,303 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" +) + +func TestHandleAuthGatewayBindPersistsGatewayBinding(t *testing.T) { + var gotAuth string + var gotUser string + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/user/self" { + http.NotFound(w, r) + return + } + gotAuth = r.Header.Get("Authorization") + gotUser = r.Header.Get("New-Api-User") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true,"message":"","data":{"id":123,"username":"weitao","display_name":"Wei Tao","email":"weitao@example.com"}}`)) + })) + defer upstream.Close() + + server, err := NewWithConfig(Config{ + NewAPIBaseURL: upstream.URL, + SQLitePath: filepath.Join(t.TempDir(), "popiart.db"), + SkillhubDir: makeEmptySkillhub(t), + SessionSecret: "test-secret", + }) + if err != nil { + t.Fatalf("NewWithConfig: %v", err) + } + + sessionToken, _, ok, err := server.store.createSession("sk-billing-user") + if err != nil { + t.Fatalf("createSession: %v", err) + } + if !ok { + t.Fatal("expected session creation to succeed") + } + + req := httptest.NewRequest(http.MethodPost, "/v1/auth/gateway/bind", strings.NewReader(`{"gateway_user_id":123,"gateway_access_token":"gate_tok_123"}`)) + req.Header.Set("Authorization", "Bearer "+sessionToken) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + server.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + if gotAuth != "Bearer gate_tok_123" { + t.Fatalf("expected bearer gateway access token, got %q", gotAuth) + } + if gotUser != "123" { + t.Fatalf("expected New-Api-User 123, got %q", gotUser) + } + + current, exists, err := server.store.session(sessionToken) + if err != nil { + t.Fatalf("session: %v", err) + } + if !exists { + t.Fatal("expected session to exist") + } + if current.GatewayUserID != 123 || current.GatewayAccessToken != "gate_tok_123" { + t.Fatalf("unexpected gateway binding in session: %#v", current) + } +} + +func TestHandleBillingSubscriptionUsesGatewayBinding(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/subscription/self" { + http.NotFound(w, r) + return + } + if r.Header.Get("Authorization") != "Bearer gate_tok_123" { + t.Fatalf("unexpected authorization: %q", r.Header.Get("Authorization")) + } + if r.Header.Get("New-Api-User") != "123" { + t.Fatalf("unexpected New-Api-User: %q", r.Header.Get("New-Api-User")) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true,"message":"","data":{"billing_preference":"subscription","subscriptions":[{"subscription":{"id":10,"status":"active"}}],"all_subscriptions":[],"subscription_points":{"10":{"available_points":500}}}}`)) + })) + defer upstream.Close() + + server, err := NewWithConfig(Config{ + NewAPIBaseURL: upstream.URL, + SQLitePath: filepath.Join(t.TempDir(), "popiart.db"), + SkillhubDir: makeEmptySkillhub(t), + SessionSecret: "test-secret", + }) + if err != nil { + t.Fatalf("NewWithConfig: %v", err) + } + + sessionToken, _, ok, err := server.store.createSession("sk-billing-user") + if err != nil { + t.Fatalf("createSession: %v", err) + } + if !ok { + t.Fatal("expected session creation to succeed") + } + if err := server.store.bindGatewaySession(sessionToken, 123, "gate_tok_123"); err != nil { + t.Fatalf("bindGatewaySession: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/v1/billing/subscription", nil) + req.Header.Set("Authorization", "Bearer "+sessionToken) + rec := httptest.NewRecorder() + server.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp struct { + OK bool `json:"ok"` + Data struct { + BillingPreference string `json:"billing_preference"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if !resp.OK || resp.Data.BillingPreference != "subscription" { + t.Fatalf("unexpected response: %s", rec.Body.String()) + } +} + +func TestHandleBillingCreditsReturnsPreconditionWithoutGatewayBinding(t *testing.T) { + server, err := NewWithConfig(Config{ + SQLitePath: filepath.Join(t.TempDir(), "popiart.db"), + SkillhubDir: makeEmptySkillhub(t), + SessionSecret: "test-secret", + }) + if err != nil { + t.Fatalf("NewWithConfig: %v", err) + } + + sessionToken, _, ok, err := server.store.createSession("sk-billing-user") + if err != nil { + t.Fatalf("createSession: %v", err) + } + if !ok { + t.Fatal("expected session creation to succeed") + } + + req := httptest.NewRequest(http.MethodGet, "/v1/billing/credits", nil) + req.Header.Set("Authorization", "Bearer "+sessionToken) + rec := httptest.NewRecorder() + server.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusPreconditionFailed { + t.Fatalf("expected 412, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestHandleBillingSubscriptionPlansUsesGatewayBinding(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/subscription/plans" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true,"message":"","data":[{"plan":{"id":1,"title":"Pro Monthly","subtitle":"月付订阅","description":"说明","price_amount":29.9,"currency":"USD","points_amount":1000,"duration_unit":"month","duration_value":1,"recommended":true,"member_level":"pro","total_amount":500000,"quota_reset_period":"monthly"}}]}`)) + })) + defer upstream.Close() + + server, err := NewWithConfig(Config{ + NewAPIBaseURL: upstream.URL, + SQLitePath: filepath.Join(t.TempDir(), "popiart.db"), + SkillhubDir: makeEmptySkillhub(t), + SessionSecret: "test-secret", + }) + if err != nil { + t.Fatalf("NewWithConfig: %v", err) + } + + sessionToken, _, ok, err := server.store.createSession("sk-billing-user") + if err != nil { + t.Fatalf("createSession: %v", err) + } + if !ok { + t.Fatal("expected session creation to succeed") + } + if err := server.store.bindGatewaySession(sessionToken, 123, "gate_tok_123"); err != nil { + t.Fatalf("bindGatewaySession: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/v1/billing/subscription/plans", nil) + req.Header.Set("Authorization", "Bearer "+sessionToken) + rec := httptest.NewRecorder() + server.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestHandleBillingCheckoutPointsUsesGatewayBinding(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/points/alipay/pay" { + http.NotFound(w, r) + return + } + if r.Header.Get("Authorization") != "Bearer gate_tok_123" { + t.Fatalf("unexpected authorization: %q", r.Header.Get("Authorization")) + } + if r.Header.Get("New-Api-User") != "123" { + t.Fatalf("unexpected New-Api-User: %q", r.Header.Get("New-Api-User")) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true,"message":"","data":{"message":"success","trade_no":"PTSUSR1NOxxxx","url":"https://openapi.alipay.com/gateway.do?..."}}`)) + })) + defer upstream.Close() + + server, err := NewWithConfig(Config{ + NewAPIBaseURL: upstream.URL, + SQLitePath: filepath.Join(t.TempDir(), "popiart.db"), + SkillhubDir: makeEmptySkillhub(t), + SessionSecret: "test-secret", + }) + if err != nil { + t.Fatalf("NewWithConfig: %v", err) + } + + sessionToken, _, ok, err := server.store.createSession("sk-billing-user") + if err != nil { + t.Fatalf("createSession: %v", err) + } + if !ok { + t.Fatal("expected session creation to succeed") + } + if err := server.store.bindGatewaySession(sessionToken, 123, "gate_tok_123"); err != nil { + t.Fatalf("bindGatewaySession: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/v1/billing/checkout/points", strings.NewReader(`{"package_id":1,"provider":"alipay","return_url":"https://example.com/pay-return"}`)) + req.Header.Set("Authorization", "Bearer "+sessionToken) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + server.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestHandleBillingCheckoutStatusUsesGatewayBinding(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/points/alipay/query" { + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("trade_no"); got != "PTSUSR1NOxxxx" { + t.Fatalf("unexpected trade_no: %q", got) + } + if r.Header.Get("Authorization") != "Bearer gate_tok_123" { + t.Fatalf("unexpected authorization: %q", r.Header.Get("Authorization")) + } + if r.Header.Get("New-Api-User") != "123" { + t.Fatalf("unexpected New-Api-User: %q", r.Header.Get("New-Api-User")) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true,"message":"","data":{"message":"success","status":"TRADE_SUCCESS","data":{}}}`)) + })) + defer upstream.Close() + + server, err := NewWithConfig(Config{ + NewAPIBaseURL: upstream.URL, + SQLitePath: filepath.Join(t.TempDir(), "popiart.db"), + SkillhubDir: makeEmptySkillhub(t), + SessionSecret: "test-secret", + }) + if err != nil { + t.Fatalf("NewWithConfig: %v", err) + } + + sessionToken, _, ok, err := server.store.createSession("sk-billing-user") + if err != nil { + t.Fatalf("createSession: %v", err) + } + if !ok { + t.Fatal("expected session creation to succeed") + } + if err := server.store.bindGatewaySession(sessionToken, 123, "gate_tok_123"); err != nil { + t.Fatalf("bindGatewaySession: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/v1/billing/checkout/status?kind=points&provider=alipay&trade_no=PTSUSR1NOxxxx", nil) + req.Header.Set("Authorization", "Bearer "+sessionToken) + rec := httptest.NewRecorder() + server.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } +} diff --git a/internal/server/models_test.go b/internal/server/models_test.go index 14d1858..ed0023c 100644 --- a/internal/server/models_test.go +++ b/internal/server/models_test.go @@ -25,6 +25,7 @@ func TestHandleModelsLoadsDynamicCatalogFromNewAPI(t *testing.T) { {"id":"seedream-4-5-251128","owned_by":"custom","supported_endpoint_types":[]}, {"id":"sora-2","owned_by":"openai","supported_endpoint_types":["openai-video"]}, {"id":"viduq2","owned_by":"custom","supported_endpoint_types":[]}, + {"id":"doubao-seedance-2-0-260128","owned_by":"custom","supported_endpoint_types":[]}, {"id":"custom-i2v-preview","owned_by":"custom","supported_endpoint_types":[]}, {"id":"gpt-4.1","owned_by":"openai","supported_endpoint_types":["openai"]} ] @@ -74,8 +75,8 @@ func TestHandleModelsLoadsDynamicCatalogFromNewAPI(t *testing.T) { if !resp.OK { t.Fatalf("expected ok response, got %s", rec.Body.String()) } - if len(resp.Data.Items) != 5 { - t.Fatalf("expected 5 supported media models, got %d: %s", len(resp.Data.Items), rec.Body.String()) + if len(resp.Data.Items) != 6 { + t.Fatalf("expected 6 supported media models, got %d: %s", len(resp.Data.Items), rec.Body.String()) } byID := make(map[string]model, len(resp.Data.Items)) @@ -133,6 +134,17 @@ func TestHandleModelsLoadsDynamicCatalogFromNewAPI(t *testing.T) { t.Fatalf("expected viduq2 provider vidu, got %q", viduModel.Provider) } + seedanceModel, exists := byID["doubao-seedance-2-0-260128"] + if !exists { + t.Fatal("expected doubao-seedance-2-0-260128 in dynamic model list") + } + if seedanceModel.Type != "video" { + t.Fatalf("expected doubao-seedance-2-0-260128 type video, got %q", seedanceModel.Type) + } + if seedanceModel.Provider != "volcengine" { + t.Fatalf("expected doubao-seedance-2-0-260128 provider volcengine, got %q", seedanceModel.Provider) + } + fallbackModel, exists := byID["custom-i2v-preview"] if !exists { t.Fatal("expected fallback-classified video model in dynamic model list") diff --git a/internal/server/newapi.go b/internal/server/newapi.go index 7eef994..c2f29df 100644 --- a/internal/server/newapi.go +++ b/internal/server/newapi.go @@ -101,6 +101,24 @@ type imageGenerationResponse struct { } `json:"error"` } +type musicGenerationResponse struct { + AudioURL string `json:"audio_url"` + Status int `json:"status"` + ExtraInfo map[string]any `json:"extra_info"` + Error *apiErrorBody `json:"error"` +} + +type speechGenerationResponse struct { + Error *apiErrorBody `json:"error"` +} + +type apiErrorBody struct { + Code any `json:"code"` + Message string `json:"message"` + Type string `json:"type"` + Param string `json:"param"` +} + type geminiImageGenerationResponse struct { Candidates []struct { Content struct { @@ -117,7 +135,7 @@ type geminiImageGenerationResponse struct { Error *struct { Message string `json:"message"` Status string `json:"status"` - Code int `json:"code"` + Code any `json:"code"` } `json:"error"` } @@ -130,6 +148,84 @@ type modelListResponse struct { } `json:"error"` } +type gatewayEnvelope[T any] struct { + Success bool `json:"success"` + Message string `json:"message"` + Data T `json:"data"` +} + +type gatewayUserSelf struct { + ID int `json:"id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Email string `json:"email"` +} + +type gatewaySubscriptionSelf struct { + BillingPreference string `json:"billing_preference"` + Subscriptions []map[string]any `json:"subscriptions"` + AllSubscriptions []map[string]any `json:"all_subscriptions"` + SubscriptionPoints map[string]any `json:"subscription_points"` +} + +type gatewayPointsMine struct { + Wallets []map[string]any `json:"wallets"` + Balance int `json:"balance"` +} + +type gatewayPagedItems struct { + Page int `json:"page"` + PageSize int `json:"page_size"` + Total int `json:"total"` + Items []map[string]any `json:"items"` +} + +type gatewaySubscriptionPlan struct { + ID int `json:"id"` + Title string `json:"title"` + Subtitle string `json:"subtitle"` + Description string `json:"description"` + PriceAmount float64 `json:"price_amount"` + Currency string `json:"currency"` + PriceAmountCNY float64 `json:"price_amount_cny"` + PriceAmountUSD float64 `json:"price_amount_usd"` + PointsAmount int `json:"points_amount"` + DurationUnit string `json:"duration_unit"` + DurationValue int `json:"duration_value"` + Recommended bool `json:"recommended"` + MemberLevel string `json:"member_level"` + TotalAmount int `json:"total_amount"` + QuotaResetPeriod string `json:"quota_reset_period"` +} + +type gatewaySubscriptionPlanItem struct { + Plan gatewaySubscriptionPlan `json:"plan"` +} + +type gatewayPointsPackage struct { + ID int `json:"id"` + Name string `json:"name"` + Currency string `json:"currency"` + PriceAmount float64 `json:"price_amount"` + PointsAmount int `json:"points_amount"` + BonusPoints int `json:"bonus_points"` + Enabled bool `json:"enabled"` +} + +type gatewayPaymentIntent struct { + Message string `json:"message"` + TradeNo string `json:"trade_no"` + URL string `json:"url,omitempty"` + CodeURL string `json:"code_url,omitempty"` + Data map[string]any `json:"data,omitempty"` +} + +type gatewayPaymentStatus struct { + Message string `json:"message"` + Status string `json:"status"` + Data map[string]any `json:"data,omitempty"` +} + type upstreamModel struct { ID string `json:"id"` Object string `json:"object,omitempty"` @@ -161,28 +257,34 @@ type openAIVideoResponse struct { } type taskEnvelopeResponse struct { - Code string `json:"code"` + Code any `json:"code"` Message string `json:"message"` Data json.RawMessage `json:"data"` } type taskDTOResponse struct { + ID string `json:"id,omitempty"` TaskID string `json:"task_id"` Status string `json:"status"` - Progress string `json:"progress"` + Progress any `json:"progress"` + Code any `json:"code,omitempty"` ResultURL string `json:"result_url,omitempty"` + URL string `json:"url,omitempty"` + VideoURL string `json:"video_url,omitempty"` FailReason string `json:"fail_reason,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` Data json.RawMessage `json:"data,omitempty"` } type videoTaskResult struct { - TaskID string - Status string - Progress string - URL string - Format string - ErrorCode string - ErrorReason string + TaskID string + Status string + Progress string + URL string + LastFrameURL string + Format string + ErrorCode string + ErrorReason string } func newNewAPIClient(cfg Config) *newAPIClient { @@ -318,6 +420,245 @@ func (c *newAPIClient) verifyKey(ctx context.Context, token string) error { return nil } +func (c *newAPIClient) gatewayRequest(ctx context.Context, method, path string, gatewayUserID int, gatewayAccessToken string, body io.Reader) ([]byte, error) { + if !c.enabled() { + return nil, errors.New("PopiNewAPI base URL is not configured") + } + if gatewayUserID <= 0 { + return nil, errors.New("gateway user id is required") + } + gatewayAccessToken = strings.TrimSpace(gatewayAccessToken) + if gatewayAccessToken == "" { + return nil, errors.New("gateway access token is required") + } + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+gatewayAccessToken) + req.Header.Set("New-Api-User", strconv.Itoa(gatewayUserID)) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + payload, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("PopiNewAPI returned status %d", resp.StatusCode) + } + return payload, nil +} + +func decodeGatewayEnvelope[T any](payload []byte, target *T) error { + var envelope gatewayEnvelope[T] + if err := json.Unmarshal(payload, &envelope); err != nil { + return err + } + if !envelope.Success { + return errors.New(strings.TrimSpace(envelope.Message)) + } + *target = envelope.Data + return nil +} + +func (c *newAPIClient) getGatewayUserSelf(ctx context.Context, gatewayUserID int, gatewayAccessToken string) (gatewayUserSelf, error) { + payload, err := c.gatewayRequest(ctx, http.MethodGet, "/api/user/self", gatewayUserID, gatewayAccessToken, nil) + if err != nil { + return gatewayUserSelf{}, err + } + var self gatewayUserSelf + if err := decodeGatewayEnvelope(payload, &self); err != nil { + return gatewayUserSelf{}, err + } + return self, nil +} + +func (c *newAPIClient) getGatewaySubscriptionSelf(ctx context.Context, gatewayUserID int, gatewayAccessToken string) (gatewaySubscriptionSelf, error) { + payload, err := c.gatewayRequest(ctx, http.MethodGet, "/api/subscription/self", gatewayUserID, gatewayAccessToken, nil) + if err != nil { + return gatewaySubscriptionSelf{}, err + } + var item gatewaySubscriptionSelf + if err := decodeGatewayEnvelope(payload, &item); err != nil { + return gatewaySubscriptionSelf{}, err + } + return item, nil +} + +func (c *newAPIClient) getGatewayPointsMine(ctx context.Context, gatewayUserID int, gatewayAccessToken string) (gatewayPointsMine, error) { + payload, err := c.gatewayRequest(ctx, http.MethodGet, "/api/points/mine", gatewayUserID, gatewayAccessToken, nil) + if err != nil { + return gatewayPointsMine{}, err + } + var item gatewayPointsMine + if err := decodeGatewayEnvelope(payload, &item); err != nil { + return gatewayPointsMine{}, err + } + return item, nil +} + +func (c *newAPIClient) listGatewaySubscriptionOrders(ctx context.Context, gatewayUserID int, gatewayAccessToken string, query string) (gatewayPagedItems, error) { + path := "/api/subscription/orders" + if strings.TrimSpace(query) != "" { + path += "?" + query + } + payload, err := c.gatewayRequest(ctx, http.MethodGet, path, gatewayUserID, gatewayAccessToken, nil) + if err != nil { + return gatewayPagedItems{}, err + } + var item gatewayPagedItems + if err := decodeGatewayEnvelope(payload, &item); err != nil { + return gatewayPagedItems{}, err + } + return item, nil +} + +func (c *newAPIClient) listGatewayPointOrders(ctx context.Context, gatewayUserID int, gatewayAccessToken string, query string) (gatewayPagedItems, error) { + path := "/api/points/orders" + if strings.TrimSpace(query) != "" { + path += "?" + query + } + payload, err := c.gatewayRequest(ctx, http.MethodGet, path, gatewayUserID, gatewayAccessToken, nil) + if err != nil { + return gatewayPagedItems{}, err + } + var item gatewayPagedItems + if err := decodeGatewayEnvelope(payload, &item); err != nil { + return gatewayPagedItems{}, err + } + return item, nil +} + +func (c *newAPIClient) listGatewaySubscriptionPlans(ctx context.Context, gatewayUserID int, gatewayAccessToken string) ([]gatewaySubscriptionPlanItem, error) { + payload, err := c.gatewayRequest(ctx, http.MethodGet, "/api/subscription/plans", gatewayUserID, gatewayAccessToken, nil) + if err != nil { + return nil, err + } + var items []gatewaySubscriptionPlanItem + if err := decodeGatewayEnvelope(payload, &items); err != nil { + return nil, err + } + return items, nil +} + +func (c *newAPIClient) listGatewayPointPackages(ctx context.Context, gatewayUserID int, gatewayAccessToken string) ([]gatewayPointsPackage, error) { + payload, err := c.gatewayRequest(ctx, http.MethodGet, "/api/points/packages", gatewayUserID, gatewayAccessToken, nil) + if err != nil { + return nil, err + } + var items []gatewayPointsPackage + if err := decodeGatewayEnvelope(payload, &items); err != nil { + return nil, err + } + return items, nil +} + +func (c *newAPIClient) createGatewaySubscriptionPayment(ctx context.Context, gatewayUserID int, gatewayAccessToken, provider string, planID int, returnURL string) (gatewayPaymentIntent, error) { + path := map[string]string{ + "alipay": "/api/subscription/alipay/pay", + "wxpay": "/api/subscription/wxpay/pay", + }[provider] + if path == "" { + return gatewayPaymentIntent{}, errors.New("unsupported subscription payment provider") + } + payload, err := marshalJSON(map[string]any{ + "plan_id": planID, + "return_url": returnURL, + }) + if err != nil { + return gatewayPaymentIntent{}, err + } + body, err := c.gatewayRequest(ctx, http.MethodPost, path, gatewayUserID, gatewayAccessToken, strings.NewReader(payload)) + if err != nil { + return gatewayPaymentIntent{}, err + } + var item gatewayPaymentIntent + if err := decodeGatewayEnvelope(body, &item); err != nil { + return gatewayPaymentIntent{}, err + } + return item, nil +} + +func (c *newAPIClient) createGatewayPointsPayment(ctx context.Context, gatewayUserID int, gatewayAccessToken, provider string, packageID int, returnURL string) (gatewayPaymentIntent, error) { + path := map[string]string{ + "alipay": "/api/points/alipay/pay", + "wxpay": "/api/points/wxpay/pay", + }[provider] + if path == "" { + return gatewayPaymentIntent{}, errors.New("unsupported points payment provider") + } + payload, err := marshalJSON(map[string]any{ + "package_id": packageID, + "return_url": returnURL, + }) + if err != nil { + return gatewayPaymentIntent{}, err + } + body, err := c.gatewayRequest(ctx, http.MethodPost, path, gatewayUserID, gatewayAccessToken, strings.NewReader(payload)) + if err != nil { + return gatewayPaymentIntent{}, err + } + var item gatewayPaymentIntent + if err := decodeGatewayEnvelope(body, &item); err != nil { + return gatewayPaymentIntent{}, err + } + return item, nil +} + +func (c *newAPIClient) queryGatewaySubscriptionPayment(ctx context.Context, gatewayUserID int, gatewayAccessToken, provider, tradeNo string) (gatewayPaymentStatus, error) { + path := map[string]string{ + "alipay": "/api/subscription/alipay/query", + "wxpay": "/api/subscription/wxpay/query", + }[provider] + if path == "" { + return gatewayPaymentStatus{}, errors.New("unsupported subscription payment provider") + } + if strings.TrimSpace(tradeNo) == "" { + return gatewayPaymentStatus{}, errors.New("trade_no is required") + } + query := url.Values{} + query.Set("trade_no", tradeNo) + payload, err := c.gatewayRequest(ctx, http.MethodGet, path+"?"+query.Encode(), gatewayUserID, gatewayAccessToken, nil) + if err != nil { + return gatewayPaymentStatus{}, err + } + var item gatewayPaymentStatus + if err := decodeGatewayEnvelope(payload, &item); err != nil { + return gatewayPaymentStatus{}, err + } + return item, nil +} + +func (c *newAPIClient) queryGatewayPointsPayment(ctx context.Context, gatewayUserID int, gatewayAccessToken, provider, tradeNo string) (gatewayPaymentStatus, error) { + path := map[string]string{ + "alipay": "/api/points/alipay/query", + "wxpay": "/api/points/wxpay/query", + }[provider] + if path == "" { + return gatewayPaymentStatus{}, errors.New("unsupported points payment provider") + } + if strings.TrimSpace(tradeNo) == "" { + return gatewayPaymentStatus{}, errors.New("trade_no is required") + } + query := url.Values{} + query.Set("trade_no", tradeNo) + payload, err := c.gatewayRequest(ctx, http.MethodGet, path+"?"+query.Encode(), gatewayUserID, gatewayAccessToken, nil) + if err != nil { + return gatewayPaymentStatus{}, err + } + var item gatewayPaymentStatus + if err := decodeGatewayEnvelope(payload, &item); err != nil { + return gatewayPaymentStatus{}, err + } + return item, nil +} + func normalizeUpstreamModel(item upstreamModel) (model, bool) { modelID := strings.TrimSpace(item.ID) if modelID == "" { @@ -349,6 +690,12 @@ func normalizeUpstreamModel(item upstreamModel) (model, bool) { func inferProviderFallback(modelID string) string { lowerID := strings.ToLower(strings.TrimSpace(modelID)) switch { + case strings.Contains(lowerID, "doubao-seedance"), + strings.Contains(lowerID, "seedance"): + return "volcengine" + case strings.Contains(lowerID, "jimeng"), + strings.Contains(lowerID, "dreamactor"): + return "jimeng" case strings.Contains(lowerID, "vidu"): return "vidu" default: @@ -389,11 +736,15 @@ func classifyModelIDFallback(modelID string) (string, []string) { switch { case strings.Contains(lowerID, "sora"), strings.Contains(lowerID, "veo"), + strings.Contains(lowerID, "doubao-seedance"), + strings.Contains(lowerID, "seedance"), strings.Contains(lowerID, "vidu"), strings.Contains(lowerID, "runway"), strings.Contains(lowerID, "kling"), strings.Contains(lowerID, "pixverse"), strings.Contains(lowerID, "hailuo"), + strings.Contains(lowerID, "jimeng"), + strings.Contains(lowerID, "dreamactor"), strings.Contains(lowerID, "hunyuan-video"), strings.Contains(lowerID, "video"), strings.Contains(lowerID, "s2v"), @@ -481,6 +832,91 @@ func (c *newAPIClient) generateImageRefs(ctx context.Context, token, modelID str return decodeImageGenerationResponse(resp.StatusCode, respBody, modelID) } +func (c *newAPIClient) generateMusicRefs(ctx context.Context, token, modelID string, input map[string]any) ([]resultRef, map[string]any, error) { + if !c.enabled() { + return nil, nil, errors.New("PopiNewAPI base URL is not configured") + } + token = c.authorizedToken(token) + if token == "" { + return nil, nil, errors.New("PopiNewAPI token is not configured") + } + payload := cloneMap(input) + payload["model"] = modelID + + body, err := json.Marshal(payload) + if err != nil { + return nil, nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/music/generations", bytes.NewReader(body)) + if err != nil { + return nil, nil, err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, err + } + return decodeMusicGenerationResponse(resp.StatusCode, resp.Header.Get("Content-Type"), respBody, modelID) +} + +func (c *newAPIClient) generateSpeechRefs(ctx context.Context, token, modelID string, input map[string]any) ([]resultRef, map[string]any, error) { + if !c.enabled() { + return nil, nil, errors.New("PopiNewAPI base URL is not configured") + } + token = c.authorizedToken(token) + if token == "" { + return nil, nil, errors.New("PopiNewAPI token is not configured") + } + prompt := strings.TrimSpace(stringValue(input["prompt"], input["text"], input["input"])) + if prompt == "" { + return nil, nil, errors.New("prompt is required") + } + format := defaultString(strings.TrimSpace(stringValue(input["format"], input["response_format"])), "mp3") + payload := map[string]any{ + "model": modelID, + "input": prompt, + "response_format": format, + } + if voice := strings.TrimSpace(stringValue(input["voice"])); voice != "" { + payload["voice"] = voice + } + if speed, ok := input["speed"]; ok { + payload["speed"] = speed + } + if metadata, ok := input["metadata"]; ok { + payload["metadata"] = metadata + } + + body, err := json.Marshal(payload) + if err != nil { + return nil, nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/audio/speech", bytes.NewReader(body)) + if err != nil { + return nil, nil, err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, err + } + return decodeSpeechGenerationResponse(resp.StatusCode, resp.Header.Get("Content-Type"), format, respBody, modelID) +} + func (c *newAPIClient) generateEditedImageRefs(ctx context.Context, token, modelID string, input map[string]any, refs []imageEditReference) ([]resultRef, map[string]any, error) { if !c.enabled() { return nil, nil, errors.New("PopiNewAPI base URL is not configured") @@ -1143,15 +1579,18 @@ func (c *newAPIClient) fetchVideoTask(ctx context.Context, token, taskID string) return nil, errors.New("PopiNewAPI token is not configured") } - result, err := c.fetchOpenAIVideoTask(ctx, token, taskID) - if err == nil { + result, err := c.fetchGenericVideoTask(ctx, token, taskID) + if err == nil && isUsableVideoTaskResult(result) { return result, nil } - fallback, fallbackErr := c.fetchGenericVideoTask(ctx, token, taskID) + fallback, fallbackErr := c.fetchOpenAIVideoTask(ctx, token, taskID) if fallbackErr == nil { return fallback, nil } - return nil, err + if err != nil { + return nil, err + } + return nil, fallbackErr } func (c *newAPIClient) fetchOpenAIVideoTask(ctx context.Context, token, taskID string) (*videoTaskResult, error) { @@ -1189,6 +1628,9 @@ func (c *newAPIClient) fetchOpenAIVideoTask(ctx context.Context, token, taskID s if text := strings.TrimSpace(stringValue(decoded.Metadata["url"])); text != "" { result.URL = text } + if text := strings.TrimSpace(stringValue(decoded.Metadata["last_frame_url"])); text != "" { + result.LastFrameURL = text + } if text := strings.TrimSpace(stringValue(decoded.Metadata["format"])); text != "" { result.Format = text } @@ -1228,50 +1670,63 @@ func (c *newAPIClient) fetchGenericVideoTask(ctx context.Context, token, taskID if err := json.Unmarshal(body, &env); err != nil { return nil, fmt.Errorf("decode PopiNewAPI task envelope: %w", err) } - if strings.TrimSpace(env.Code) != "" && !strings.EqualFold(env.Code, "success") { + if !isTaskEnvelopeSuccessCode(env.Code) { return nil, errors.New(defaultString(strings.TrimSpace(env.Message), "PopiNewAPI video task fetch failed")) } + if result, ok := parseGenericVideoTaskPayload(env.Data, taskID); ok { + return result, nil + } + return nil, fmt.Errorf("decode PopiNewAPI generic task payload: missing task id") +} +func parseGenericVideoTaskPayload(raw json.RawMessage, fallbackTaskID string) (*videoTaskResult, bool) { var task taskDTOResponse - if err := json.Unmarshal(env.Data, &task); err == nil && strings.TrimSpace(task.TaskID) != "" { + if err := json.Unmarshal(raw, &task); err == nil && firstNonEmptyString(task.TaskID, task.ID, fallbackTaskID) != "" { return &videoTaskResult{ - TaskID: strings.TrimSpace(task.TaskID), - Status: normalizeVideoTaskStatus(task.Status), - Progress: normalizeVideoProgress(0, task.Progress), - URL: strings.TrimSpace(task.ResultURL), - ErrorReason: strings.TrimSpace(task.FailReason), - }, nil + TaskID: firstNonEmptyString(task.TaskID, task.ID, fallbackTaskID), + Status: normalizeGatewayVideoTaskStatus(task.Status, task.Code), + Progress: normalizeVideoProgressValue(task.Progress), + URL: firstNonEmptyString(task.ResultURL, task.URL, task.VideoURL, stringValue(task.Metadata["url"]), resultURLFromRawJSON(task.Data)), + LastFrameURL: strings.TrimSpace(stringValue(task.Metadata["last_frame_url"])), + ErrorReason: strings.TrimSpace(task.FailReason), + }, true } var simple struct { - TaskID string `json:"task_id"` - Status string `json:"status"` - URL string `json:"url"` - Format string `json:"format"` - Progress string `json:"progress"` - Error *struct { + ID string `json:"id,omitempty"` + TaskID string `json:"task_id"` + Status string `json:"status"` + Code any `json:"code,omitempty"` + URL string `json:"url"` + ResultURL string `json:"result_url,omitempty"` + VideoURL string `json:"video_url,omitempty"` + Format string `json:"format"` + Progress any `json:"progress"` + Metadata map[string]any `json:"metadata,omitempty"` + Error *struct { Code string `json:"code"` Message string `json:"message"` } `json:"error"` } - if err := json.Unmarshal(env.Data, &simple); err != nil { - return nil, fmt.Errorf("decode PopiNewAPI generic task payload: %w", err) + if err := json.Unmarshal(raw, &simple); err != nil { + return nil, false } result := &videoTaskResult{ - TaskID: strings.TrimSpace(simple.TaskID), - Status: normalizeVideoTaskStatus(simple.Status), - URL: strings.TrimSpace(simple.URL), - Format: strings.TrimSpace(simple.Format), - Progress: normalizeVideoProgress(0, simple.Progress), + TaskID: firstNonEmptyString(simple.TaskID, simple.ID, fallbackTaskID), + Status: normalizeGatewayVideoTaskStatus(simple.Status, simple.Code), + URL: firstNonEmptyString(simple.URL, simple.ResultURL, simple.VideoURL, stringValue(simple.Metadata["url"])), + LastFrameURL: strings.TrimSpace(stringValue(simple.Metadata["last_frame_url"])), + Format: strings.TrimSpace(simple.Format), + Progress: normalizeVideoProgressValue(simple.Progress), } if simple.Error != nil { result.ErrorCode = strings.TrimSpace(simple.Error.Code) result.ErrorReason = strings.TrimSpace(simple.Error.Message) } if result.TaskID == "" { - result.TaskID = taskID + return nil, false } - return result, nil + return result, true } func decodeImageGenerationResponse(statusCode int, body []byte, nameHint string) ([]resultRef, map[string]any, error) { @@ -1328,6 +1783,86 @@ func decodeImageGenerationResponse(statusCode int, body []byte, nameHint string) return nil, decoded.Usage, errors.New("PopiNewAPI returned neither b64_json nor url") } +func decodeMusicGenerationResponse(statusCode int, contentType string, body []byte, nameHint string) ([]resultRef, map[string]any, error) { + if statusCode >= 400 || strings.Contains(strings.ToLower(contentType), "json") { + var decoded musicGenerationResponse + if err := json.Unmarshal(body, &decoded); err != nil { + return nil, nil, fmt.Errorf("decode PopiNewAPI music response: %w", err) + } + if decoded.Error != nil && strings.TrimSpace(decoded.Error.Message) != "" { + return nil, decoded.ExtraInfo, errors.New(decoded.Error.Message) + } + if statusCode >= 400 { + return nil, decoded.ExtraInfo, fmt.Errorf("PopiNewAPI returned status %d", statusCode) + } + if strings.TrimSpace(decoded.AudioURL) == "" { + return nil, decoded.ExtraInfo, errors.New("PopiNewAPI returned no audio_url") + } + return []resultRef{{ + Kind: "url", + URL: decoded.AudioURL, + Filename: filenameFromURL(decoded.AudioURL, fmt.Sprintf("%s-%d.mp3", sanitizeFilename(nameHint), time.Now().UTC().Unix())), + ContentType: "audio/mpeg", + }}, decoded.ExtraInfo, nil + } + + if len(body) == 0 { + return nil, nil, errors.New("PopiNewAPI returned empty audio body") + } + contentType = defaultString(strings.TrimSpace(contentType), "audio/mpeg") + encoded := base64.StdEncoding.EncodeToString(body) + return []resultRef{{ + Kind: "data_url", + DataURL: "data:" + contentType + ";base64," + encoded, + Filename: fmt.Sprintf("%s-%d%s", sanitizeFilename(nameHint), time.Now().UTC().Unix(), extensionFromContentType(contentType)), + ContentType: contentType, + SizeBytes: int64(len(body)), + }}, nil, nil +} + +func decodeSpeechGenerationResponse(statusCode int, contentType, format string, body []byte, nameHint string) ([]resultRef, map[string]any, error) { + if statusCode >= 400 || strings.Contains(strings.ToLower(contentType), "json") { + var decoded speechGenerationResponse + if err := json.Unmarshal(body, &decoded); err != nil { + return nil, nil, fmt.Errorf("decode PopiNewAPI speech response: %w", err) + } + if decoded.Error != nil && strings.TrimSpace(decoded.Error.Message) != "" { + return nil, nil, errors.New(decoded.Error.Message) + } + if statusCode >= 400 { + return nil, nil, fmt.Errorf("PopiNewAPI returned status %d", statusCode) + } + return nil, nil, errors.New("PopiNewAPI returned JSON instead of audio") + } + if len(body) == 0 { + return nil, nil, errors.New("PopiNewAPI returned empty speech body") + } + contentType = defaultString(strings.TrimSpace(contentType), getContentTypeByAudioFormat(format)) + encoded := base64.StdEncoding.EncodeToString(body) + return []resultRef{{ + Kind: "data_url", + DataURL: "data:" + contentType + ";base64," + encoded, + Filename: fmt.Sprintf("%s-%d%s", sanitizeFilename(nameHint), time.Now().UTC().Unix(), extensionFromContentType(contentType)), + ContentType: contentType, + SizeBytes: int64(len(body)), + }}, nil, nil +} + +func getContentTypeByAudioFormat(format string) string { + switch strings.ToLower(strings.TrimSpace(format)) { + case "wav": + return "audio/wav" + case "aac": + return "audio/aac" + case "flac": + return "audio/flac" + case "pcm": + return "audio/pcm" + default: + return "audio/mpeg" + } +} + func decodeGeminiImageGenerationResponse(statusCode int, body []byte, nameHint string) ([]resultRef, map[string]any, error) { var decoded geminiImageGenerationResponse if err := json.Unmarshal(body, &decoded); err != nil { @@ -1395,6 +1930,18 @@ func useMiniMaxVideoGenerations(modelID string) bool { strings.Contains(lowerID, "minimax-hailuo") } +func useSeedanceVideoGenerations(modelID string) bool { + lowerID := strings.ToLower(strings.TrimSpace(modelID)) + return strings.Contains(lowerID, "doubao-seedance") || + strings.Contains(lowerID, "seedance") +} + +func useJimengVideoGenerations(modelID string) bool { + lowerID := strings.ToLower(strings.TrimSpace(modelID)) + return strings.Contains(lowerID, "jimeng") || + strings.Contains(lowerID, "dreamactor") +} + func resolveGeminiAspectRatio(input map[string]any) string { if aspectRatio := normalizeImageAspectRatio(stringValue(input["aspect_ratio"])); aspectRatio != "" { if _, ok := seedreamAspectRatioSizes[aspectRatio]; ok { @@ -1745,6 +2292,306 @@ func resolveMiniMaxVideoDuration(input map[string]any) int { return duration } +func (c *newAPIClient) submitSeedanceVideoTask(ctx context.Context, token, modelID string, input map[string]any) (string, error) { + if !c.enabled() { + return "", errors.New("PopiNewAPI base URL is not configured") + } + token = c.authorizedToken(token) + if token == "" { + return "", errors.New("PopiNewAPI token is not configured") + } + + payload, err := buildSeedanceVideoGenerationPayload(modelID, input) + if err != nil { + return "", err + } + body, err := json.Marshal(payload) + if err != nil { + return "", err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/video/generations", bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + if resp.StatusCode >= 400 { + return "", decodeTaskAPIError(respBody, resp.StatusCode) + } + + taskID := decodeVideoSubmitTaskID(respBody) + if taskID == "" { + return "", errors.New("PopiNewAPI returned no task id") + } + return taskID, nil +} + +func buildSeedanceVideoGenerationPayload(modelID string, input map[string]any) (map[string]any, error) { + modelID = strings.TrimSpace(modelID) + if modelID == "" { + return nil, errors.New("Seedance model id is required") + } + payload := map[string]any{ + "model": modelID, + } + if prompt := strings.TrimSpace(stringValue(input["prompt"])); prompt != "" { + payload["prompt"] = prompt + } + if size := strings.TrimSpace(stringValue(input["size"])); size != "" { + payload["size"] = size + } + if duration := resolvePositiveInt(input["duration"]); duration > 0 { + payload["duration"] = duration + } else if duration := resolvePositiveInt(input["duration_s"]); duration > 0 { + payload["duration"] = duration + } + if images := extractStringValues(input["images"]); len(images) > 0 { + payload["images"] = images + } + if videos := extractStringValues(input["videos"]); len(videos) > 0 { + payload["videos"] = videos + } + if audios := extractStringValues(input["audios"]); len(audios) > 0 { + payload["audios"] = audios + } + + metadata := map[string]any{} + if raw, ok := input["metadata"].(map[string]any); ok { + for key, value := range raw { + if strings.TrimSpace(key) == "" || value == nil { + continue + } + metadata[key] = value + } + } + for _, key := range []string{ + "action", + "seed", + "frames", + "ratio", + "return_last_frame", + "generate_audio", + "service_tier", + "execution_expires_after", + "draft", + "safety_identifier", + } { + if value, ok := input[key]; ok && value != nil { + metadata[key] = value + } + } + if tools, ok := input["tools"]; ok && tools != nil { + metadata["tools"] = tools + } + if len(metadata) > 0 { + payload["metadata"] = metadata + } + return payload, nil +} + +func (c *newAPIClient) submitJimengVideoTask(ctx context.Context, token, modelID string, input map[string]any) (string, error) { + if !c.enabled() { + return "", errors.New("PopiNewAPI base URL is not configured") + } + token = c.authorizedToken(token) + if token == "" { + return "", errors.New("PopiNewAPI token is not configured") + } + + payload, err := buildJimengVideoGenerationPayload(modelID, input) + if err != nil { + return "", err + } + body, err := json.Marshal(payload) + if err != nil { + return "", err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/video/generations", bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + if resp.StatusCode >= 400 { + return "", decodeTaskAPIError(respBody, resp.StatusCode) + } + + taskID := decodeVideoSubmitTaskID(respBody) + if taskID == "" { + return "", errors.New("PopiNewAPI returned no task id") + } + return taskID, nil +} + +func buildJimengVideoGenerationPayload(modelID string, input map[string]any) (map[string]any, error) { + modelID = strings.TrimSpace(modelID) + if modelID == "" { + return nil, errors.New("Jimeng model id is required") + } + payload := map[string]any{ + "model": modelID, + } + if prompt := strings.TrimSpace(stringValue(input["prompt"])); prompt != "" { + payload["prompt"] = prompt + } + if images := normalizeJimengImageInputs(extractStringValues(input["images"])); len(images) > 0 { + payload["images"] = images + } + if videos := extractStringValues(input["videos"]); len(videos) > 0 { + payload["videos"] = videos + } + if duration := resolveJimengDuration(input); duration > 0 { + payload["duration"] = duration + } + metadata := buildJimengMetadata(input) + if useJimengDreamActorModel(modelID) { + if _, ok := metadata["action"]; !ok { + metadata["action"] = "actionGenerate" + } + } + if len(metadata) > 0 { + payload["metadata"] = metadata + } + return payload, nil +} + +func useJimengDreamActorModel(modelID string) bool { + lowerID := strings.ToLower(strings.TrimSpace(modelID)) + return strings.Contains(lowerID, "dreamactor") +} + +func normalizeJimengImageInputs(values []string) []string { + out := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if stripped, ok := stripImageDataURLPrefix(value); ok { + value = stripped + } + out = append(out, value) + } + return out +} + +func stripImageDataURLPrefix(value string) (string, bool) { + value = strings.TrimSpace(value) + lower := strings.ToLower(value) + if !strings.HasPrefix(lower, "data:image/") { + return value, false + } + comma := strings.Index(value, ",") + if comma < 0 { + return value, true + } + return strings.TrimSpace(value[comma+1:]), true +} + +func resolveJimengDuration(input map[string]any) int { + duration := resolvePositiveInt(input["duration"]) + if duration == 0 { + duration = resolvePositiveInt(input["duration_s"]) + } + if duration == 0 { + duration = resolvePositiveInt(input["seconds"]) + } + if duration == 5 || duration == 10 { + return duration + } + return 0 +} + +func buildJimengMetadata(input map[string]any) map[string]any { + metadata := map[string]any{} + if raw, ok := input["metadata"].(map[string]any); ok { + for key, value := range raw { + if strings.TrimSpace(key) == "" || value == nil { + continue + } + metadata[key] = value + } + } + for _, key := range []string{ + "action", + "seed", + "frames", + "aspect_ratio", + "template_id", + "camera_strength", + "cut_result_first_second_switch", + } { + if value, ok := input[key]; ok && value != nil { + metadata[key] = value + } + } + if _, exists := metadata["frames"]; !exists { + switch resolveJimengDuration(input) { + case 5: + metadata["frames"] = 121 + case 10: + metadata["frames"] = 241 + } + } + return metadata +} + +func resolvePositiveInt(value any) int { + switch typed := value.(type) { + case int: + if typed > 0 { + return typed + } + case int32: + if typed > 0 { + return int(typed) + } + case int64: + if typed > 0 { + return int(typed) + } + case float64: + if typed > 0 { + return int(typed) + } + case json.Number: + parsed, err := typed.Int64() + if err == nil && parsed > 0 { + return int(parsed) + } + case string: + parsed, err := strconv.Atoi(strings.TrimSpace(typed)) + if err == nil && parsed > 0 { + return parsed + } + } + return 0 +} + func (c *newAPIClient) submitMiniMaxVideoTask(ctx context.Context, token, modelID string, input map[string]any, refs []imageEditReference) (string, error) { if !c.enabled() { return "", errors.New("PopiNewAPI base URL is not configured") @@ -1903,19 +2750,109 @@ func normalizeViduResolution(size string) string { func normalizeVideoTaskStatus(status string) string { switch strings.ToLower(strings.TrimSpace(status)) { - case "queued", "pending", "submitted": + case "queued", "pending", "submitted", "in_queue": return "queued" - case "processing", "in_progress", "running": + case "processing", "in_progress", "running", "generating": return "in_progress" case "completed", "done", "success", "succeeded": return "completed" - case "failed", "failure", "cancelled", "canceled": + case "failed", "failure", "cancelled", "canceled", "expired", "not_found": return "failed" default: return "" } } +func isUsableVideoTaskResult(result *videoTaskResult) bool { + if result == nil || strings.TrimSpace(result.TaskID) == "" { + return false + } + if strings.TrimSpace(result.Status) != "" { + return true + } + return strings.TrimSpace(result.URL) != "" +} + +func isTaskEnvelopeSuccessCode(code any) bool { + if code == nil { + return true + } + switch typed := code.(type) { + case string: + value := strings.TrimSpace(typed) + return value == "" || value == "0" || strings.EqualFold(value, "success") + case float64: + return typed == 0 + case int: + return typed == 0 + case int32: + return typed == 0 + case int64: + return typed == 0 + case json.Number: + parsed, err := typed.Int64() + return err == nil && parsed == 0 + default: + return false + } +} + +func normalizeGatewayVideoTaskStatus(status string, code any) string { + normalized := normalizeVideoTaskStatus(status) + if strings.EqualFold(strings.TrimSpace(status), "done") && !isGatewaySuccessCode(code) { + return "failed" + } + return normalized +} + +func isGatewaySuccessCode(code any) bool { + if code == nil { + return true + } + switch typed := code.(type) { + case int: + return typed == 10000 || typed == 0 + case int32: + return typed == 10000 || typed == 0 + case int64: + return typed == 10000 || typed == 0 + case float64: + return typed == 10000 || typed == 0 + case json.Number: + parsed, err := typed.Int64() + return err != nil || parsed == 10000 || parsed == 0 + case string: + value := strings.TrimSpace(typed) + return value == "" || value == "10000" || value == "0" + default: + return true + } +} + +func firstNonEmptyString(values ...string) string { + for _, value := range values { + if text := strings.TrimSpace(value); text != "" { + return text + } + } + return "" +} + +func resultURLFromRawJSON(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var payload map[string]any + if err := json.Unmarshal(raw, &payload); err != nil { + return "" + } + return strings.TrimSpace(stringValue( + payload["result_url"], + payload["url"], + payload["video_url"], + )) +} + func normalizeVideoProgress(progress int, fallback string) string { if progress > 0 { return fmt.Sprintf("%d%%", progress) @@ -1927,6 +2864,29 @@ func normalizeVideoProgress(progress int, fallback string) string { return "" } +func normalizeVideoProgressValue(value any) string { + switch typed := value.(type) { + case int: + return normalizeVideoProgress(typed, "") + case int32: + return normalizeVideoProgress(int(typed), "") + case int64: + return normalizeVideoProgress(int(typed), "") + case float64: + return normalizeVideoProgress(int(typed), "") + case json.Number: + parsed, err := typed.Int64() + if err == nil { + return normalizeVideoProgress(int(parsed), "") + } + return "" + case string: + return normalizeVideoProgress(0, typed) + default: + return "" + } +} + func decodeTaskAPIError(body []byte, statusCode int) error { var taskErr struct { Code string `json:"code"` diff --git a/internal/server/newapi_image_test.go b/internal/server/newapi_image_test.go index 57b2e11..773cfc7 100644 --- a/internal/server/newapi_image_test.go +++ b/internal/server/newapi_image_test.go @@ -427,3 +427,13 @@ func TestGenerateGeminiImageRefsAnnotatesMultiImageRoles(t *testing.T) { t.Fatalf("unexpected style label: %#v", got) } } + +func TestDecodeGeminiImageGenerationResponseAcceptsStringErrorCode(t *testing.T) { + _, _, err := decodeGeminiImageGenerationResponse(http.StatusBadRequest, []byte(`{"error":{"code":"model_not_found","message":"model unavailable","status":"INVALID_ARGUMENT"}}`), "gemini-3.1-flash-image-preview") + if err == nil { + t.Fatal("expected Gemini error response to fail") + } + if !strings.Contains(err.Error(), "model unavailable") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/internal/server/newapi_music_test.go b/internal/server/newapi_music_test.go new file mode 100644 index 0000000..e1b882c --- /dev/null +++ b/internal/server/newapi_music_test.go @@ -0,0 +1,81 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestGenerateMusicRefsUsesMusicGenerationsEndpoint(t *testing.T) { + var gotPath string + var gotBody map[string]any + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode request body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "audio_url": "https://example.com/music-output.mp3", + "status": 2, + "extra_info": { + "music_duration": 34, + "music_sample_rate": 44100, + "music_channel": 2, + "bitrate": 256000, + "music_size": 1098543 + } + }`)) + })) + defer srv.Close() + + client := &newAPIClient{ + baseURL: strings.TrimRight(srv.URL, "/"), + httpClient: srv.Client(), + } + + refs, usage, err := client.generateMusicRefs(context.Background(), "sk-test", "music-2.6", map[string]any{ + "prompt": "lofi rainy night piano", + "is_instrumental": true, + "output_format": "url", + "audio_setting": map[string]any{ + "format": "mp3", + }, + }) + if err != nil { + t.Fatalf("generateMusicRefs: %v", err) + } + if gotPath != "/v1/music/generations" { + t.Fatalf("unexpected path: %s", gotPath) + } + if gotBody["model"] != "music-2.6" { + t.Fatalf("unexpected model: %#v", gotBody["model"]) + } + if gotBody["is_instrumental"] != true { + t.Fatalf("unexpected is_instrumental: %#v", gotBody["is_instrumental"]) + } + if len(refs) != 1 || refs[0].URL != "https://example.com/music-output.mp3" || refs[0].ContentType != "audio/mpeg" { + t.Fatalf("unexpected refs: %#v", refs) + } + if usage["music_duration"] != float64(34) { + t.Fatalf("unexpected usage: %#v", usage) + } +} + +func TestDecodeMusicGenerationResponseAcceptsNumericErrorCode(t *testing.T) { + _, _, err := decodeMusicGenerationResponse(http.StatusBadRequest, "application/json", []byte(`{ + "error": { + "message": "minimax music error: 1004 - auth failed", + "type": "bad_response", + "param": "", + "code": 1004 + } + }`), "music-2.6") + if err == nil || !strings.Contains(err.Error(), "auth failed") { + t.Fatalf("expected decoded upstream error, got %v", err) + } +} diff --git a/internal/server/newapi_speech_test.go b/internal/server/newapi_speech_test.go new file mode 100644 index 0000000..7037d52 --- /dev/null +++ b/internal/server/newapi_speech_test.go @@ -0,0 +1,61 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestGenerateSpeechRefsUsesAudioSpeechEndpoint(t *testing.T) { + var gotPath string + var gotBody map[string]any + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode request body: %v", err) + } + w.Header().Set("Content-Type", "audio/mpeg") + _, _ = w.Write([]byte("fake-mp3")) + })) + defer srv.Close() + + client := &newAPIClient{ + baseURL: strings.TrimRight(srv.URL, "/"), + httpClient: srv.Client(), + } + + refs, _, err := client.generateSpeechRefs(context.Background(), "sk-test", "speech-2.8-hd", map[string]any{ + "prompt": "今天想去上海走一走。", + "format": "mp3", + }) + if err != nil { + t.Fatalf("generateSpeechRefs: %v", err) + } + if gotPath != "/v1/audio/speech" { + t.Fatalf("unexpected path: %s", gotPath) + } + if gotBody["model"] != "speech-2.8-hd" || gotBody["input"] != "今天想去上海走一走。" || gotBody["response_format"] != "mp3" { + t.Fatalf("unexpected body: %#v", gotBody) + } + if len(refs) != 1 || refs[0].Kind != "data_url" || refs[0].ContentType != "audio/mpeg" { + t.Fatalf("unexpected refs: %#v", refs) + } +} + +func TestDecodeSpeechGenerationResponseAcceptsNumericErrorCode(t *testing.T) { + _, _, err := decodeSpeechGenerationResponse(http.StatusBadRequest, "application/json", "mp3", []byte(`{ + "error": { + "message": "minimax TTS error: 1004 - auth failed", + "type": "bad_response", + "param": "", + "code": 1004 + } + }`), "speech-2.8-hd") + if err == nil || !strings.Contains(err.Error(), "auth failed") { + t.Fatalf("expected decoded upstream error, got %v", err) + } +} diff --git a/internal/server/repository.go b/internal/server/repository.go index 5b84a93..2c39f3b 100644 --- a/internal/server/repository.go +++ b/internal/server/repository.go @@ -7,6 +7,7 @@ type SessionRepository interface { GetSession(token string) (session, bool, error) DeleteSession(token string) error RotateSession(oldToken string, ttl time.Duration) (session, bool, error) + UpdateSessionGatewayBinding(token string, gatewayUserID int, gatewayAccessToken string) error } type JobRepository interface { diff --git a/internal/server/routes_test.go b/internal/server/routes_test.go index 7dfd875..7010af3 100644 --- a/internal/server/routes_test.go +++ b/internal/server/routes_test.go @@ -23,16 +23,20 @@ func TestConfigFromEnvDefaultsVideoRouteToViduQ2(t *testing.T) { func TestInferRouteKeyForModelRecognizesViduAsVideo(t *testing.T) { cases := map[string]string{ - "viduq2": "video.image2video", - "vidu2.0": "video.image2video", - "sora-2": "video.image2video", - "runway-gen4": "video.image2video", - "T2V-01": "video.image2video", - "I2V-01": "video.image2video", - "S2V-01": "video.image2video", - "MiniMax-Hailuo-2.3": "video.image2video", - "MiniMax-Hailuo-02": "video.image2video", - "gpt-image-1": "image.text2image", + "viduq2": "video.image2video", + "vidu2.0": "video.image2video", + "sora-2": "video.image2video", + "runway-gen4": "video.image2video", + "T2V-01": "video.image2video", + "I2V-01": "video.image2video", + "S2V-01": "video.image2video", + "MiniMax-Hailuo-2.3": "video.image2video", + "MiniMax-Hailuo-02": "video.image2video", + "doubao-seedance-2-0-260128": "video.image2video", + "seedance-2-0-fast-260128": "video.image2video", + "jimeng_dreamactor_m20_gen_video": "video.image2video", + "jimeng_i2v_first_v30": "video.image2video", + "gpt-image-1": "image.text2image", } for modelID, want := range cases { @@ -56,3 +60,29 @@ func TestInferRouteKeyForModelRecognizesImageEditInputs(t *testing.T) { } } } + +func TestInferRouteKeyForModelRecognizesMusic(t *testing.T) { + cases := []string{ + "music-2.6", + "music-2.6-free", + "music-cover", + "music-cover-free", + } + for _, modelID := range cases { + if got := inferRouteKeyForModel(modelID, nil); got != "music.generate" { + t.Fatalf("inferRouteKeyForModel(%q) = %q, want music.generate", modelID, got) + } + } + if got := inferRouteKeyForModelType("custom-model", "music", nil); got != "music.generate" { + t.Fatalf("inferRouteKeyForModelType(custom-model, music) = %q, want music.generate", got) + } +} + +func TestInferRouteKeyForModelRecognizesSpeech(t *testing.T) { + if got := inferRouteKeyForModel("speech-2.8-hd", nil); got != "speech.synthesize" { + t.Fatalf("inferRouteKeyForModel(speech-2.8-hd) = %q, want speech.synthesize", got) + } + if got := inferRouteKeyForModelType("custom-model", "speech", nil); got != "speech.synthesize" { + t.Fatalf("inferRouteKeyForModelType(custom-model, speech) = %q, want speech.synthesize", got) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 933b6b6..b936523 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -9,6 +9,7 @@ import ( "io" "log" "net/http" + "net/url" "os" "path/filepath" "strconv" @@ -57,6 +58,7 @@ func (s *Server) routes() { s.mux.HandleFunc("/v1/auth/me", s.handleAuthMe) s.mux.HandleFunc("/v1/auth/logout", s.handleAuthLogout) s.mux.HandleFunc("/v1/auth/token/rotate", s.handleAuthTokenRotate) + s.mux.HandleFunc("/v1/auth/gateway/bind", s.handleAuthGatewayBind) s.mux.HandleFunc("/v1/skills", s.handleSkills) s.mux.HandleFunc("/v1/skills/", s.handleSkill) s.mux.HandleFunc("/v1/jobs", s.handleJobs) @@ -69,6 +71,15 @@ func (s *Server) routes() { s.mux.HandleFunc("/v1/budget", s.handleBudget) s.mux.HandleFunc("/v1/budget/usage", s.handleBudgetUsage) s.mux.HandleFunc("/v1/budget/limits", s.handleBudgetLimits) + s.mux.HandleFunc("/v1/billing/catalog", s.handleBillingCatalog) + s.mux.HandleFunc("/v1/billing/subscription/plans", s.handleBillingSubscriptionPlans) + s.mux.HandleFunc("/v1/billing/points/packages", s.handleBillingPointPackages) + s.mux.HandleFunc("/v1/billing/subscription", s.handleBillingSubscription) + s.mux.HandleFunc("/v1/billing/credits", s.handleBillingCredits) + s.mux.HandleFunc("/v1/billing/invoices", s.handleBillingInvoices) + s.mux.HandleFunc("/v1/billing/checkout/subscription", s.handleBillingCheckoutSubscription) + s.mux.HandleFunc("/v1/billing/checkout/points", s.handleBillingCheckoutPoints) + s.mux.HandleFunc("/v1/billing/checkout/status", s.handleBillingCheckoutStatus) s.mux.HandleFunc("/v1/projects", s.handleProjects) s.mux.HandleFunc("/v1/projects/", s.handleProject) s.mux.HandleFunc("/v1/models", s.handleModels) @@ -76,6 +87,8 @@ func (s *Server) routes() { s.mux.HandleFunc("/v1/models/routes/overrides", s.handleModelRouteOverrides) s.mux.HandleFunc("/v1/models/routes/overrides/unset", s.handleModelRouteOverrideUnset) s.mux.HandleFunc("/v1/models/infer", s.handleModelsInfer) + s.mux.HandleFunc("/v1/video/generations", s.handleVideoGenerations) + s.mux.HandleFunc("/v1/video/generations/", s.handleVideoGeneration) } func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { @@ -141,10 +154,17 @@ func (s *Server) handleAuthMe(w http.ResponseWriter, r *http.Request) { if !ok { return } + gatewayAccessTokenMasked := "" + if strings.TrimSpace(current.GatewayAccessToken) != "" { + gatewayAccessTokenMasked = maskSecret(current.GatewayAccessToken) + } writeData(w, http.StatusOK, authSessionView{ - User: current.User, - SessionKey: current.Token, - UpstreamKeyMasked: maskSecret(current.UpstreamKey), + User: current.User, + SessionKey: current.Token, + UpstreamKeyMasked: maskSecret(current.UpstreamKey), + GatewayUserID: current.GatewayUserID, + GatewayAccessTokenMasked: gatewayAccessTokenMasked, + GatewayBound: current.GatewayUserID > 0 && strings.TrimSpace(current.GatewayAccessToken) != "", }) } @@ -186,6 +206,56 @@ func (s *Server) handleAuthTokenRotate(w http.ResponseWriter, r *http.Request) { }) } +func (s *Server) handleAuthGatewayBind(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + methodNotAllowed(w) + return + } + current, ok := s.authenticateSession(w, r) + if !ok { + return + } + if s.newapi == nil || !s.newapi.enabled() { + writeError(w, http.StatusServiceUnavailable, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil) + return + } + var req struct { + GatewayUserID int `json:"gateway_user_id"` + GatewayAccessToken string `json:"gateway_access_token"` + } + if !decodeJSON(w, r, &req) { + return + } + if req.GatewayUserID <= 0 || strings.TrimSpace(req.GatewayAccessToken) == "" { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "gateway_user_id and gateway_access_token are required", nil) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + self, err := s.newapi.getGatewayUserSelf(ctx, req.GatewayUserID, req.GatewayAccessToken) + if err != nil { + writeError(w, http.StatusBadGateway, "GATEWAY_BIND_FAILED", "failed to validate gateway user binding", map[string]any{ + "details": err.Error(), + }) + return + } + if self.ID != req.GatewayUserID { + writeError(w, http.StatusUnauthorized, "UNAUTHENTICATED", "gateway user id does not match gateway access token", nil) + return + } + if err := s.store.bindGatewaySession(current.Token, req.GatewayUserID, req.GatewayAccessToken); err != nil { + writeInternalError(w, "failed to persist gateway binding", err) + return + } + writeData(w, http.StatusOK, map[string]any{ + "gateway_user_id": self.ID, + "gateway_username": self.Username, + "gateway_display_name": self.DisplayName, + "gateway_email": self.Email, + "gateway_bound": true, + }) +} + func (s *Server) handleSkills(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { methodNotAllowed(w) @@ -841,6 +911,372 @@ func (s *Server) handleBudgetLimits(w http.ResponseWriter, r *http.Request) { }) } +func billingCatalogSeed() []billingPlan { + return []billingPlan{ + { + ID: "creator-pro-monthly", + ProductType: "subscription", + Badge: "Recommended", + Name: "Creator Plan", + Price: "¥69.99", + Cadence: "/month", + Summary: "For solo creators and small teams that need a stable monthly credit baseline.", + Features: []string{ + "12,000 monthly credits", + "Official skill catalog access", + "Project-level usage visibility", + "Shared web console and CLI auth flow", + }, + CTA: "Choose Creator Plan", + Highlight: true, + }, + { + ID: "credits-top-up-50000", + ProductType: "top_up", + Badge: "Flexible", + Name: "Credits Top-up", + Price: "¥199", + Cadence: "/pack", + Summary: "Add extra spendable credits when video, batch, or campaign work spikes.", + Features: []string{ + "50,000 extra credits", + "Stacks on top of subscriptions", + "Visible in the same billing center", + "Designed for bursty production workloads", + }, + CTA: "Buy credits", + }, + } +} + +func (s *Server) handleBillingCatalog(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w) + return + } + writeData(w, http.StatusOK, map[string]any{ + "plans": billingCatalogSeed(), + "capabilities": map[string]any{ + "subscription_status": false, + "credit_balance": false, + "invoices": false, + "checkout": false, + }, + "source": "popiartServer", + "note": "Catalog is product-layer data. Checkout, subscription, credits, and invoices are not wired yet.", + }) +} + +func (s *Server) handleBillingSubscription(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w) + return + } + current, ok := s.authenticateSession(w, r) + if !ok { + return + } + if s.newapi == nil || !s.newapi.enabled() { + writeError(w, http.StatusServiceUnavailable, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil) + return + } + if current.GatewayUserID <= 0 || strings.TrimSpace(current.GatewayAccessToken) == "" { + writeError(w, http.StatusPreconditionFailed, "BILLING_UNBOUND", "gateway billing is not bound for this session", nil) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + item, err := s.newapi.getGatewaySubscriptionSelf(ctx, current.GatewayUserID, current.GatewayAccessToken) + if err != nil { + writeError(w, http.StatusBadGateway, "BILLING_FETCH_FAILED", "failed to load subscription data from PopiNewAPI", map[string]any{ + "details": err.Error(), + }) + return + } + writeData(w, http.StatusOK, item) +} + +func (s *Server) handleBillingSubscriptionPlans(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w) + return + } + current, ok := s.authenticateSession(w, r) + if !ok { + return + } + if s.newapi == nil || !s.newapi.enabled() { + writeError(w, http.StatusServiceUnavailable, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil) + return + } + if current.GatewayUserID <= 0 || strings.TrimSpace(current.GatewayAccessToken) == "" { + writeError(w, http.StatusPreconditionFailed, "BILLING_UNBOUND", "gateway billing is not bound for this session", nil) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + items, err := s.newapi.listGatewaySubscriptionPlans(ctx, current.GatewayUserID, current.GatewayAccessToken) + if err != nil { + writeError(w, http.StatusBadGateway, "BILLING_FETCH_FAILED", "failed to load subscription plans from PopiNewAPI", map[string]any{ + "details": err.Error(), + }) + return + } + writeData(w, http.StatusOK, map[string]any{"items": items}) +} + +func (s *Server) handleBillingPointPackages(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w) + return + } + current, ok := s.authenticateSession(w, r) + if !ok { + return + } + if s.newapi == nil || !s.newapi.enabled() { + writeError(w, http.StatusServiceUnavailable, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil) + return + } + if current.GatewayUserID <= 0 || strings.TrimSpace(current.GatewayAccessToken) == "" { + writeError(w, http.StatusPreconditionFailed, "BILLING_UNBOUND", "gateway billing is not bound for this session", nil) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + items, err := s.newapi.listGatewayPointPackages(ctx, current.GatewayUserID, current.GatewayAccessToken) + if err != nil { + writeError(w, http.StatusBadGateway, "BILLING_FETCH_FAILED", "failed to load point packages from PopiNewAPI", map[string]any{ + "details": err.Error(), + }) + return + } + writeData(w, http.StatusOK, map[string]any{"items": items}) +} + +func (s *Server) handleBillingCredits(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w) + return + } + current, ok := s.authenticateSession(w, r) + if !ok { + return + } + if s.newapi == nil || !s.newapi.enabled() { + writeError(w, http.StatusServiceUnavailable, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil) + return + } + if current.GatewayUserID <= 0 || strings.TrimSpace(current.GatewayAccessToken) == "" { + writeError(w, http.StatusPreconditionFailed, "BILLING_UNBOUND", "gateway billing is not bound for this session", nil) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + item, err := s.newapi.getGatewayPointsMine(ctx, current.GatewayUserID, current.GatewayAccessToken) + if err != nil { + writeError(w, http.StatusBadGateway, "BILLING_FETCH_FAILED", "failed to load credit data from PopiNewAPI", map[string]any{ + "details": err.Error(), + }) + return + } + writeData(w, http.StatusOK, item) +} + +func (s *Server) handleBillingInvoices(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w) + return + } + current, ok := s.authenticateSession(w, r) + if !ok { + return + } + if s.newapi == nil || !s.newapi.enabled() { + writeError(w, http.StatusServiceUnavailable, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil) + return + } + if current.GatewayUserID <= 0 || strings.TrimSpace(current.GatewayAccessToken) == "" { + writeError(w, http.StatusPreconditionFailed, "BILLING_UNBOUND", "gateway billing is not bound for this session", nil) + return + } + query := r.URL.Query() + params := make(url.Values) + if value := strings.TrimSpace(query.Get("keyword")); value != "" { + params.Set("keyword", value) + } + if value := strings.TrimSpace(query.Get("p")); value != "" { + params.Set("p", value) + } + if value := strings.TrimSpace(query.Get("page_size")); value != "" { + params.Set("page_size", value) + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + subscriptions, err := s.newapi.listGatewaySubscriptionOrders(ctx, current.GatewayUserID, current.GatewayAccessToken, params.Encode()) + if err != nil { + writeError(w, http.StatusBadGateway, "BILLING_FETCH_FAILED", "failed to load subscription orders from PopiNewAPI", map[string]any{ + "details": err.Error(), + }) + return + } + points, err := s.newapi.listGatewayPointOrders(ctx, current.GatewayUserID, current.GatewayAccessToken, params.Encode()) + if err != nil { + writeError(w, http.StatusBadGateway, "BILLING_FETCH_FAILED", "failed to load point orders from PopiNewAPI", map[string]any{ + "details": err.Error(), + }) + return + } + writeData(w, http.StatusOK, map[string]any{ + "subscription_orders": subscriptions, + "point_orders": points, + }) +} + +func (s *Server) handleBillingCheckoutSubscription(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + methodNotAllowed(w) + return + } + current, ok := s.authenticateSession(w, r) + if !ok { + return + } + if s.newapi == nil || !s.newapi.enabled() { + writeError(w, http.StatusServiceUnavailable, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil) + return + } + if current.GatewayUserID <= 0 || strings.TrimSpace(current.GatewayAccessToken) == "" { + writeError(w, http.StatusPreconditionFailed, "BILLING_UNBOUND", "gateway billing is not bound for this session", nil) + return + } + var req struct { + PlanID int `json:"plan_id"` + Provider string `json:"provider"` + ReturnURL string `json:"return_url"` + } + if !decodeJSON(w, r, &req) { + return + } + if req.PlanID <= 0 || strings.TrimSpace(req.Provider) == "" { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "plan_id and provider are required", nil) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + item, err := s.newapi.createGatewaySubscriptionPayment( + ctx, + current.GatewayUserID, + current.GatewayAccessToken, + strings.TrimSpace(strings.ToLower(req.Provider)), + req.PlanID, + strings.TrimSpace(req.ReturnURL), + ) + if err != nil { + writeError(w, http.StatusBadGateway, "BILLING_CHECKOUT_FAILED", "failed to create subscription payment", map[string]any{ + "details": err.Error(), + }) + return + } + writeData(w, http.StatusOK, item) +} + +func (s *Server) handleBillingCheckoutPoints(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + methodNotAllowed(w) + return + } + current, ok := s.authenticateSession(w, r) + if !ok { + return + } + if s.newapi == nil || !s.newapi.enabled() { + writeError(w, http.StatusServiceUnavailable, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil) + return + } + if current.GatewayUserID <= 0 || strings.TrimSpace(current.GatewayAccessToken) == "" { + writeError(w, http.StatusPreconditionFailed, "BILLING_UNBOUND", "gateway billing is not bound for this session", nil) + return + } + var req struct { + PackageID int `json:"package_id"` + Provider string `json:"provider"` + ReturnURL string `json:"return_url"` + } + if !decodeJSON(w, r, &req) { + return + } + if req.PackageID <= 0 || strings.TrimSpace(req.Provider) == "" { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "package_id and provider are required", nil) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + item, err := s.newapi.createGatewayPointsPayment( + ctx, + current.GatewayUserID, + current.GatewayAccessToken, + strings.TrimSpace(strings.ToLower(req.Provider)), + req.PackageID, + strings.TrimSpace(req.ReturnURL), + ) + if err != nil { + writeError(w, http.StatusBadGateway, "BILLING_CHECKOUT_FAILED", "failed to create points payment", map[string]any{ + "details": err.Error(), + }) + return + } + writeData(w, http.StatusOK, item) +} + +func (s *Server) handleBillingCheckoutStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w) + return + } + current, ok := s.authenticateSession(w, r) + if !ok { + return + } + if s.newapi == nil || !s.newapi.enabled() { + writeError(w, http.StatusServiceUnavailable, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil) + return + } + if current.GatewayUserID <= 0 || strings.TrimSpace(current.GatewayAccessToken) == "" { + writeError(w, http.StatusPreconditionFailed, "BILLING_UNBOUND", "gateway billing is not bound for this session", nil) + return + } + kind := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("kind"))) + provider := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("provider"))) + tradeNo := strings.TrimSpace(r.URL.Query().Get("trade_no")) + if kind == "" || provider == "" || tradeNo == "" { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "kind, provider, and trade_no are required", nil) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + var ( + item gatewayPaymentStatus + err error + ) + switch kind { + case "subscription": + item, err = s.newapi.queryGatewaySubscriptionPayment(ctx, current.GatewayUserID, current.GatewayAccessToken, provider, tradeNo) + case "points": + item, err = s.newapi.queryGatewayPointsPayment(ctx, current.GatewayUserID, current.GatewayAccessToken, provider, tradeNo) + default: + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "kind must be subscription or points", nil) + return + } + if err != nil { + writeError(w, http.StatusBadGateway, "BILLING_FETCH_FAILED", "failed to query payment status", map[string]any{ + "details": err.Error(), + }) + return + } + writeData(w, http.StatusOK, item) +} + func (s *Server) handleProjects(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { methodNotAllowed(w) @@ -1077,6 +1513,7 @@ func (s *Server) handleModelsInfer(w http.ResponseWriter, r *http.Request) { } var req struct { ModelID string `json:"model_id"` + ModelType string `json:"model_type"` Input map[string]any `json:"input"` ProjectID string `json:"project_id"` Priority string `json:"priority"` @@ -1089,7 +1526,7 @@ func (s *Server) handleModelsInfer(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "model_id is required", nil) return } - routeKey := inferRouteKeyForModel(req.ModelID, req.Input) + routeKey := inferRouteKeyForModelType(req.ModelID, req.ModelType, req.Input) record, statusCode, err := s.store.createJob( "", routeKey, @@ -1115,6 +1552,133 @@ func (s *Server) handleModelsInfer(w http.ResponseWriter, r *http.Request) { }) } +func (s *Server) handleVideoGenerations(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/video/generations" { + notFound(w) + return + } + if r.Method != http.MethodPost { + methodNotAllowed(w) + return + } + current, ok := s.authenticateSession(w, r) + if !ok { + return + } + if s.newapi == nil || !s.newapi.enabled() { + writeError(w, http.StatusServiceUnavailable, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil) + return + } + + var req map[string]any + if !decodeJSON(w, r, &req) { + return + } + modelID := strings.TrimSpace(stringValue(req["model"])) + if modelID == "" { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "model is required", nil) + return + } + if !isSeedanceVideoModel(modelID) { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "only Seedance video generation is supported on this gateway-compatible route", map[string]any{ + "model": modelID, + }) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) + defer cancel() + taskID, err := s.newapi.submitSeedanceVideoTask(ctx, current.UpstreamKey, modelID, req) + if err != nil { + writeError(w, http.StatusBadGateway, "UPSTREAM_ERROR", "failed to submit Seedance video task", map[string]any{ + "details": err.Error(), + }) + return + } + writeData(w, http.StatusAccepted, map[string]any{ + "task_id": taskID, + "status": "PENDING", + }) +} + +func (s *Server) handleVideoGeneration(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w) + return + } + current, ok := s.authenticateSession(w, r) + if !ok { + return + } + if s.newapi == nil || !s.newapi.enabled() { + writeError(w, http.StatusServiceUnavailable, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil) + return + } + taskID := strings.TrimPrefix(r.URL.Path, "/v1/video/generations/") + taskID = strings.Trim(taskID, "/") + if taskID == "" || strings.Contains(taskID, "/") { + notFound(w) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + result, err := s.newapi.fetchVideoTask(ctx, current.UpstreamKey, taskID) + if err != nil { + writeError(w, http.StatusBadGateway, "UPSTREAM_ERROR", "failed to fetch Seedance video task", map[string]any{ + "details": err.Error(), + }) + return + } + writeData(w, http.StatusOK, videoGenerationResponse(result)) +} + +func videoGenerationResponse(result *videoTaskResult) map[string]any { + if result == nil { + return map[string]any{} + } + status := strings.ToUpper(strings.TrimSpace(result.Status)) + switch status { + case "COMPLETED": + status = "SUCCESS" + case "QUEUED": + status = "PENDING" + case "IN_PROGRESS": + status = "RUNNING" + case "": + status = "PENDING" + } + out := map[string]any{ + "task_id": result.TaskID, + "status": status, + } + if strings.TrimSpace(result.Progress) != "" { + out["progress"] = result.Progress + } + metadata := map[string]any{} + if strings.TrimSpace(result.URL) != "" { + out["result_url"] = strings.TrimSpace(result.URL) + metadata["url"] = strings.TrimSpace(result.URL) + } + if strings.TrimSpace(result.LastFrameURL) != "" { + out["last_frame_url"] = strings.TrimSpace(result.LastFrameURL) + metadata["last_frame_url"] = strings.TrimSpace(result.LastFrameURL) + } + if strings.TrimSpace(result.Format) != "" { + metadata["format"] = strings.TrimSpace(result.Format) + } + if len(metadata) > 0 { + out["metadata"] = metadata + } + if strings.TrimSpace(result.ErrorCode) != "" || strings.TrimSpace(result.ErrorReason) != "" { + out["error"] = map[string]any{ + "code": strings.TrimSpace(result.ErrorCode), + "message": strings.TrimSpace(result.ErrorReason), + } + } + return out +} + func (s *Server) dispatchJob(record *job) { if record == nil { return @@ -1127,6 +1691,10 @@ func (s *Server) dispatchJob(record *job) { s.executeImageToImageJob(record) case "video.image2video": s.executeImageToVideoJob(record) + case "music.generate": + s.executeMusicGenerationJob(record) + case "speech.synthesize": + s.executeSpeechSynthesisJob(record) default: s.executeUnsupportedSkill(record) } @@ -1287,7 +1855,21 @@ func (s *Server) executeImageToVideoJob(record *job) { var refsForGateway []imageEditReference var ref imageEditReference var err error - if useMiniMaxVideoGenerations(modelID) { + if useSeedanceVideoGenerations(modelID) { + // Seedance routes through unified JSON video generations and can accept + // text-only, image, video, and audio references directly from input. + } else if useJimengVideoGenerations(modelID) { + if err := validateJimengVideoInput(modelID, input); err != nil { + if repoErr := s.store.failJob(record.JobID, "VALIDATION_ERROR", err.Error(), map[string]any{ + "skill_id": record.SkillID, + "route_key": record.RouteKey, + "model_id": modelID, + }); repoErr != nil { + log.Printf("popiartServer: fail job %s failed: %v", record.JobID, repoErr) + } + return + } + } else if useMiniMaxVideoGenerations(modelID) { if hasVideoReferenceInput(input) { refsForMiniMax, err = s.resolveVideoReferences(submitCtx, record, input) if err != nil { @@ -1325,7 +1907,11 @@ func (s *Server) executeImageToVideoJob(record *job) { } var upstreamTaskID string - if useMiniMaxVideoGenerations(modelID) { + if useSeedanceVideoGenerations(modelID) { + upstreamTaskID, err = s.newapi.submitSeedanceVideoTask(submitCtx, record.UpstreamKey, modelID, input) + } else if useJimengVideoGenerations(modelID) { + upstreamTaskID, err = s.newapi.submitJimengVideoTask(submitCtx, record.UpstreamKey, modelID, input) + } else if useMiniMaxVideoGenerations(modelID) { upstreamTaskID, err = s.newapi.submitMiniMaxVideoTask(submitCtx, record.UpstreamKey, modelID, input, refsForMiniMax) } else if len(refsForGateway) > 1 { upstreamTaskID, err = s.newapi.submitImageToVideoTaskWithReferences(submitCtx, record.UpstreamKey, modelID, input, refsForGateway) @@ -1410,7 +1996,27 @@ func hasVideoReferenceInput(input map[string]any) bool { input["image"], input["image_url"], input["reference_image_url"], - )) != "" || len(extractStringValues(input["images"])) > 0 + )) != "" || len(extractStringValues(input["images"])) > 0 || len(extractStringValues(input["videos"])) > 0 +} + +func validateJimengVideoInput(modelID string, input map[string]any) error { + images := extractStringValues(input["images"]) + videos := extractStringValues(input["videos"]) + action := strings.TrimSpace(stringValue(nestedMetadataValue(input, "action"))) + if action == "" && useJimengDreamActorModel(modelID) { + action = "actionGenerate" + } + switch action { + case "actionGenerate": + if len(images) != 1 || len(videos) == 0 || strings.TrimSpace(videos[0]) == "" { + return fmt.Errorf("jimeng actionGenerate requires exactly one image and videos[0]") + } + default: + // Let PopiNewAPI / upstream enforce model-specific image counts for + // text, first-frame, first-tail, and recamera Jimeng models. + return nil + } + return nil } func (s *Server) executeUnsupportedSkill(record *job) { @@ -1427,6 +2033,94 @@ func (s *Server) executeUnsupportedSkill(record *job) { } } +func (s *Server) executeMusicGenerationJob(record *job) { + if _, _, err := s.store.startJob(record.JobID); err != nil { + log.Printf("popiartServer: start job %s failed: %v", record.JobID, err) + } + + if s.newapi == nil || !s.newapi.enabled() { + if err := s.store.failJob(record.JobID, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil); err != nil { + log.Printf("popiartServer: fail job %s failed: %v", record.JobID, err) + } + return + } + + modelID := strings.TrimSpace(record.ModelID) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + refs, usage, err := s.newapi.generateMusicRefs(ctx, record.UpstreamKey, modelID, record.Input) + if err != nil { + if repoErr := s.store.failJob(record.JobID, "MODEL_REQUEST_FAILED", err.Error(), map[string]any{ + "model_id": modelID, + "route_key": record.RouteKey, + }); repoErr != nil { + log.Printf("popiartServer: fail job %s failed: %v", record.JobID, repoErr) + } + return + } + + refs, err = s.persistResultRefs(ctx, record, refs) + if err != nil { + if repoErr := s.store.failJob(record.JobID, "MEDIA_PERSIST_FAILED", "failed to persist generated audio", map[string]any{ + "details": err.Error(), + "model_id": modelID, + "route_key": record.RouteKey, + }); repoErr != nil { + log.Printf("popiartServer: fail job %s failed: %v", record.JobID, repoErr) + } + return + } + + if err := s.store.completeJobWithResults(record.JobID, refs, usage); err != nil { + log.Printf("popiartServer: complete job %s failed: %v", record.JobID, err) + } +} + +func (s *Server) executeSpeechSynthesisJob(record *job) { + if _, _, err := s.store.startJob(record.JobID); err != nil { + log.Printf("popiartServer: start job %s failed: %v", record.JobID, err) + } + + if s.newapi == nil || !s.newapi.enabled() { + if err := s.store.failJob(record.JobID, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil); err != nil { + log.Printf("popiartServer: fail job %s failed: %v", record.JobID, err) + } + return + } + + modelID := strings.TrimSpace(record.ModelID) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + refs, usage, err := s.newapi.generateSpeechRefs(ctx, record.UpstreamKey, modelID, record.Input) + if err != nil { + if repoErr := s.store.failJob(record.JobID, "MODEL_REQUEST_FAILED", err.Error(), map[string]any{ + "model_id": modelID, + "route_key": record.RouteKey, + }); repoErr != nil { + log.Printf("popiartServer: fail job %s failed: %v", record.JobID, repoErr) + } + return + } + + refs, err = s.persistResultRefs(ctx, record, refs) + if err != nil { + if repoErr := s.store.failJob(record.JobID, "MEDIA_PERSIST_FAILED", "failed to persist generated speech", map[string]any{ + "details": err.Error(), + "model_id": modelID, + "route_key": record.RouteKey, + }); repoErr != nil { + log.Printf("popiartServer: fail job %s failed: %v", record.JobID, repoErr) + } + return + } + + if err := s.store.completeJobWithResults(record.JobID, refs, usage); err != nil { + log.Printf("popiartServer: complete job %s failed: %v", record.JobID, err) + } +} + func (s *Server) resolveModelID(routeKey, projectID string) string { routes, err := s.store.routesForProject(projectID) if err == nil { @@ -1443,6 +2137,22 @@ func (s *Server) resolveModelID(routeKey, projectID string) string { } func inferRouteKeyForModel(modelID string, input map[string]any) string { + return inferRouteKeyForModelType(modelID, "", input) +} + +func inferRouteKeyForModelType(modelID, modelType string, input map[string]any) string { + switch strings.ToLower(strings.TrimSpace(modelType)) { + case "music", "audio.music": + return "music.generate" + case "speech", "audio.speech", "tts": + return "speech.synthesize" + } + if isMusicModelID(modelID) { + return "music.generate" + } + if isSpeechModelID(modelID) { + return "speech.synthesize" + } if modelType, _ := classifyModelIDFallback(modelID); modelType == "video" { return "video.image2video" } @@ -1459,6 +2169,25 @@ func inferRouteKeyForModel(modelID string, input map[string]any) string { return "image.text2image" } +func isSpeechModelID(modelID string) bool { + normalized := strings.ToLower(strings.TrimSpace(modelID)) + return strings.HasPrefix(normalized, "speech-") || strings.Contains(normalized, "tts") +} + +func isMusicModelID(modelID string) bool { + normalized := strings.ToLower(strings.TrimSpace(modelID)) + return normalized == "music-2.6" || + normalized == "music-2.6-free" || + normalized == "music-cover" || + normalized == "music-cover-free" +} + +func isSeedanceVideoModel(modelID string) bool { + normalized := strings.ToLower(strings.TrimSpace(modelID)) + return strings.Contains(normalized, "doubao-seedance") || + strings.Contains(normalized, "seedance") +} + func (s *Server) resolveImageToImageReferences(ctx context.Context, record *job, input map[string]any) ([]imageEditReference, error) { if record == nil { return nil, fmt.Errorf("job record is required") @@ -1724,11 +2453,15 @@ func buildImageToVideoInput(record *job) map[string]any { if aspectRatio := strings.TrimSpace(stringValue(input["aspect_ratio"])); aspectRatio != "" { parts = append(parts, "aspect ratio "+aspectRatio) } - if len(parts) == 0 { + if len(parts) == 0 && !isActionGenerateInput(input) { parts = append(parts, "Generate a short polished cinematic motion clip that preserves the reference image subject identity.") } - input["prompt"] = strings.Join(parts, ", ") + if len(parts) > 0 { + input["prompt"] = strings.Join(parts, ", ") + } else { + delete(input, "prompt") + } delete(input, "motion_prompt") delete(input, "scene_prompt") return input @@ -1802,6 +2535,21 @@ func ensureInputMetadataAction(input map[string]any, action string) { } } +func isActionGenerateInput(input map[string]any) bool { + return strings.TrimSpace(stringValue(nestedMetadataValue(input, "action"), input["action"])) == "actionGenerate" +} + +func nestedMetadataValue(input map[string]any, key string) any { + if input == nil { + return nil + } + metadata, ok := input["metadata"].(map[string]any) + if !ok || metadata == nil { + return nil + } + return metadata[key] +} + func buildAliceShowcasePrompt(input map[string]any, scenePrompt string) string { parts := []string{ "PopiStudio Alice as the same fixed main protagonist from the canonical Alice reference image", diff --git a/internal/server/sqlite_repo.go b/internal/server/sqlite_repo.go index a50c2bd..879df8a 100644 --- a/internal/server/sqlite_repo.go +++ b/internal/server/sqlite_repo.go @@ -56,6 +56,8 @@ func (r *sqliteRepository) migrate() error { user_json TEXT NOT NULL, token_enc BLOB NOT NULL, token_masked TEXT NOT NULL, + gateway_user_id INTEGER, + gateway_access_token_enc BLOB, created_at TEXT NOT NULL, expires_at TEXT NOT NULL );`, @@ -154,6 +156,14 @@ func (r *sqliteRepository) migrate() error { return fmt.Errorf("upgrade sqlite jobs schema: %w", err) } } + for _, stmt := range []string{ + `ALTER TABLE sessions ADD COLUMN gateway_user_id INTEGER;`, + `ALTER TABLE sessions ADD COLUMN gateway_access_token_enc BLOB;`, + } { + if _, err := r.db.Exec(stmt); err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") { + return fmt.Errorf("upgrade sqlite sessions schema: %w", err) + } + } return nil } @@ -194,17 +204,27 @@ func (r *sqliteRepository) CreateSession(upstreamKey string, u user, ttl time.Du func (r *sqliteRepository) GetSession(token string) (session, bool, error) { row := r.db.QueryRow( - `SELECT user_id, user_json, token_enc, created_at, expires_at + `SELECT user_id, user_json, token_enc, gateway_user_id, gateway_access_token_enc, created_at, expires_at FROM sessions WHERE session_id = ?`, token, ) var ( - current session - userJSON string - tokenEnc []byte - createdAt, expiresAt string + current session + userJSON string + tokenEnc []byte + gatewayUserID sql.NullInt64 + gatewayAccessTokenEnc []byte + createdAt, expiresAt string ) - if err := row.Scan(¤t.UserID, &userJSON, &tokenEnc, &createdAt, &expiresAt); errors.Is(err, sql.ErrNoRows) { + if err := row.Scan( + ¤t.UserID, + &userJSON, + &tokenEnc, + &gatewayUserID, + &gatewayAccessTokenEnc, + &createdAt, + &expiresAt, + ); errors.Is(err, sql.ErrNoRows) { return session{}, false, nil } else if err != nil { return session{}, false, fmt.Errorf("scan session: %w", err) @@ -217,6 +237,16 @@ func (r *sqliteRepository) GetSession(token string) (session, bool, error) { if err != nil { return session{}, false, err } + if gatewayUserID.Valid { + current.GatewayUserID = int(gatewayUserID.Int64) + } + if len(gatewayAccessTokenEnc) > 0 { + gatewayAccessToken, err := r.decryptSecret(gatewayAccessTokenEnc) + if err != nil { + return session{}, false, err + } + current.GatewayAccessToken = gatewayAccessToken + } current.Token = token current.UpstreamKey = upstreamKey current.CreatedAt = parseRFC3339(createdAt) @@ -248,9 +278,41 @@ func (r *sqliteRepository) RotateSession(oldToken string, ttl time.Duration) (se if err != nil { return session{}, false, err } + if current.GatewayUserID > 0 && strings.TrimSpace(current.GatewayAccessToken) != "" { + if err := r.UpdateSessionGatewayBinding(next.Token, current.GatewayUserID, current.GatewayAccessToken); err != nil { + return session{}, false, err + } + next.GatewayUserID = current.GatewayUserID + next.GatewayAccessToken = current.GatewayAccessToken + } return next, true, nil } +func (r *sqliteRepository) UpdateSessionGatewayBinding(token string, gatewayUserID int, gatewayAccessToken string) error { + if strings.TrimSpace(token) == "" { + return errors.New("session token is required") + } + if gatewayUserID <= 0 { + return errors.New("gateway user id is required") + } + if strings.TrimSpace(gatewayAccessToken) == "" { + return errors.New("gateway access token is required") + } + enc, err := r.encryptSecret(gatewayAccessToken) + if err != nil { + return err + } + if _, err := r.db.Exec( + `UPDATE sessions SET gateway_user_id = ?, gateway_access_token_enc = ? WHERE session_id = ?`, + gatewayUserID, + enc, + token, + ); err != nil { + return fmt.Errorf("update session gateway binding: %w", err) + } + return nil +} + func (r *sqliteRepository) CreateJob(record *job) (*job, int, error) { if record == nil { return nil, 0, errors.New("job record is required") diff --git a/internal/server/store.go b/internal/server/store.go index 76e5d1a..861d237 100644 --- a/internal/server/store.go +++ b/internal/server/store.go @@ -152,6 +152,10 @@ func (s *store) rotateSession(oldToken string) (string, user, bool, error) { return current.Token, current.User, true, nil } +func (s *store) bindGatewaySession(token string, gatewayUserID int, gatewayAccessToken string) error { + return s.sessions.UpdateSessionGatewayBinding(token, gatewayUserID, gatewayAccessToken) +} + func (s *store) listSkills(tag, search string, limit, offset int) ([]skill, int) { filtered := make([]skill, 0, len(s.skills)) for _, item := range s.skills { diff --git a/internal/server/types.go b/internal/server/types.go index 5853213..839f672 100644 --- a/internal/server/types.go +++ b/internal/server/types.go @@ -12,18 +12,23 @@ type user struct { } type session struct { - Token string - UserID string - UpstreamKey string - User user - CreatedAt time.Time - ExpiresAt time.Time + Token string + UserID string + UpstreamKey string + GatewayUserID int + GatewayAccessToken string + User user + CreatedAt time.Time + ExpiresAt time.Time } type authSessionView struct { - User user `json:"user"` - SessionKey string `json:"session_key,omitempty"` - UpstreamKeyMasked string `json:"upstream_key_masked,omitempty"` + User user `json:"user"` + SessionKey string `json:"session_key,omitempty"` + UpstreamKeyMasked string `json:"upstream_key_masked,omitempty"` + GatewayUserID int `json:"gateway_user_id,omitempty"` + GatewayAccessTokenMasked string `json:"gateway_access_token_masked,omitempty"` + GatewayBound bool `json:"gateway_bound"` } type skill struct { @@ -138,3 +143,16 @@ type model struct { Capabilities []string `json:"capabilities"` Pricing map[string]any `json:"pricing"` } + +type billingPlan struct { + ID string `json:"id"` + ProductType string `json:"product_type"` + Badge string `json:"badge"` + Name string `json:"name"` + Price string `json:"price"` + Cadence string `json:"cadence"` + Summary string `json:"summary"` + Features []string `json:"features"` + CTA string `json:"cta"` + Highlight bool `json:"highlight,omitempty"` +} diff --git a/internal/server/video_test.go b/internal/server/video_test.go index 84471ae..571c6da 100644 --- a/internal/server/video_test.go +++ b/internal/server/video_test.go @@ -288,6 +288,243 @@ func TestSubmitMiniMaxVideoTaskKeepsS2VImagesArray(t *testing.T) { } } +func TestSubmitJimengVideoTaskUsesVideoGenerationsEndpoint(t *testing.T) { + var gotPath string + var gotBody map[string]any + + imageBase64 := base64.StdEncoding.EncodeToString([]byte("fake-jimeng-image-body")) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + if got := r.Header.Get("Content-Type"); got != "application/json" { + t.Fatalf("expected application/json content type, got %q", got) + } + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode request body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":"success","data":{"task_id":"task_jimeng_action_123","status":"in_queue"}}`)) + })) + defer srv.Close() + + client := newNewAPIClient(Config{NewAPIBaseURL: srv.URL}) + taskID, err := client.submitJimengVideoTask(context.Background(), "sk-test", "jimeng_dreamactor_m20_gen_video", map[string]any{ + "images": []any{"data:image/jpeg;base64," + imageBase64}, + "videos": []any{"https://example.com/source-action.mp4"}, + "metadata": map[string]any{ + "cut_result_first_second_switch": true, + }, + }) + if err != nil { + t.Fatalf("submitJimengVideoTask: %v", err) + } + if taskID != "task_jimeng_action_123" { + t.Fatalf("unexpected task id: %q", taskID) + } + if gotPath != "/v1/video/generations" { + t.Fatalf("expected /v1/video/generations, got %q", gotPath) + } + if gotBody["model"] != "jimeng_dreamactor_m20_gen_video" { + t.Fatalf("unexpected model: %#v", gotBody["model"]) + } + images, ok := gotBody["images"].([]any) + if !ok || len(images) != 1 || images[0] != imageBase64 { + t.Fatalf("expected pure base64 image payload, got %#v", gotBody["images"]) + } + videos, ok := gotBody["videos"].([]any) + if !ok || len(videos) != 1 || videos[0] != "https://example.com/source-action.mp4" { + t.Fatalf("unexpected videos payload: %#v", gotBody["videos"]) + } + metadata, ok := gotBody["metadata"].(map[string]any) + if !ok || metadata["action"] != "actionGenerate" || metadata["cut_result_first_second_switch"] != true { + t.Fatalf("unexpected metadata: %#v", gotBody["metadata"]) + } +} + +func TestBuildJimengVideoGenerationPayloadMapsDurationToFrames(t *testing.T) { + payload, err := buildJimengVideoGenerationPayload("jimeng_t2v_v30", map[string]any{ + "prompt": "cinematic cavalry charge", + "duration": 10, + "aspect_ratio": "16:9", + "seed": -1, + }) + if err != nil { + t.Fatalf("buildJimengVideoGenerationPayload: %v", err) + } + if payload["duration"] != 10 { + t.Fatalf("expected duration 10, got %#v", payload["duration"]) + } + metadata := payload["metadata"].(map[string]any) + if metadata["frames"] != 241 { + t.Fatalf("expected frames 241, got %#v", metadata["frames"]) + } + if metadata["aspect_ratio"] != "16:9" || metadata["seed"] != -1 { + t.Fatalf("unexpected metadata: %#v", metadata) + } +} + +func TestSubmitSeedanceVideoTaskUsesVideoGenerationsEndpoint(t *testing.T) { + var gotPath string + var gotBody map[string]any + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode request body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"task_seedance_123","status":"submitted"}`)) + })) + defer srv.Close() + + client := newNewAPIClient(Config{NewAPIBaseURL: srv.URL}) + taskID, err := client.submitSeedanceVideoTask(context.Background(), "sk-test", "doubao-seedance-2-0-260128", map[string]any{ + "prompt": "keep the motion style consistent", + "size": "720p", + "duration": 5, + "images": []any{"https://example.com/a.jpg", "https://example.com/b.jpg"}, + "videos": []any{"https://example.com/ref.mp4"}, + "audios": []any{"https://example.com/ref.mp3"}, + "ratio": "16:9", + "return_last_frame": true, + }) + if err != nil { + t.Fatalf("submitSeedanceVideoTask: %v", err) + } + if taskID != "task_seedance_123" { + t.Fatalf("unexpected task id: %q", taskID) + } + if gotPath != "/v1/video/generations" { + t.Fatalf("expected /v1/video/generations, got %q", gotPath) + } + if gotBody["model"] != "doubao-seedance-2-0-260128" { + t.Fatalf("unexpected model: %#v", gotBody["model"]) + } + metadata := gotBody["metadata"].(map[string]any) + if metadata["ratio"] != "16:9" || metadata["return_last_frame"] != true { + t.Fatalf("unexpected metadata: %#v", metadata) + } +} + +func TestHandleVideoGenerationsRelaysSeedanceRequest(t *testing.T) { + var gotBody map[string]any + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/v1/video/generations" { + t.Fatalf("unexpected upstream request: %s %s", r.Method, r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer sk-test-upstream" { + t.Fatalf("unexpected upstream auth: %q", got) + } + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode upstream body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":"success","data":{"task_id":"task_seedance_direct_123","status":"PENDING"}}`)) + })) + defer upstream.Close() + + server, err := NewWithConfig(Config{ + NewAPIBaseURL: upstream.URL, + SQLitePath: filepath.Join(t.TempDir(), "popiart.db"), + SkillhubDir: makeEmptySkillhub(t), + SessionSecret: "test-secret", + }) + if err != nil { + t.Fatalf("NewWithConfig: %v", err) + } + token, _, ok, err := server.store.createSession("sk-test-upstream") + if err != nil { + t.Fatalf("createSession: %v", err) + } + if !ok { + t.Fatal("expected session creation to succeed") + } + + req := httptest.NewRequest(http.MethodPost, "/v1/video/generations", strings.NewReader(`{"model":"doubao-seedance-2-0-260128","prompt":"ping","metadata":{"ratio":"16:9"}}`)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + server.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusAccepted { + t.Fatalf("expected 202, got %d body=%s", rec.Code, rec.Body.String()) + } + if gotBody["model"] != "doubao-seedance-2-0-260128" || gotBody["prompt"] != "ping" { + t.Fatalf("unexpected upstream body: %#v", gotBody) + } + var env struct { + OK bool `json:"ok"` + Data struct { + TaskID string `json:"task_id"` + Status string `json:"status"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil { + t.Fatalf("decode response: %v", err) + } + if !env.OK || env.Data.TaskID != "task_seedance_direct_123" || env.Data.Status != "PENDING" { + t.Fatalf("unexpected response: %#v", env) + } +} + +func TestHandleVideoGenerationFetchSurfacesLastFrameURL(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/v1/video/generations/task_seedance_direct_123" { + t.Fatalf("unexpected upstream request: %s %s", r.Method, r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer sk-test-upstream" { + t.Fatalf("unexpected upstream auth: %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":"success","data":{"task_id":"task_seedance_direct_123","status":"SUCCESS","metadata":{"url":"https://cdn.example.com/video.mp4","last_frame_url":"https://cdn.example.com/last-frame.png"}}}`)) + })) + defer upstream.Close() + + server, err := NewWithConfig(Config{ + NewAPIBaseURL: upstream.URL, + SQLitePath: filepath.Join(t.TempDir(), "popiart.db"), + SkillhubDir: makeEmptySkillhub(t), + SessionSecret: "test-secret", + }) + if err != nil { + t.Fatalf("NewWithConfig: %v", err) + } + token, _, ok, err := server.store.createSession("sk-test-upstream") + if err != nil { + t.Fatalf("createSession: %v", err) + } + if !ok { + t.Fatal("expected session creation to succeed") + } + + req := httptest.NewRequest(http.MethodGet, "/v1/video/generations/task_seedance_direct_123", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + server.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + var env struct { + OK bool `json:"ok"` + Data struct { + TaskID string `json:"task_id"` + Status string `json:"status"` + ResultURL string `json:"result_url"` + LastFrameURL string `json:"last_frame_url"` + Metadata map[string]any `json:"metadata"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil { + t.Fatalf("decode response: %v", err) + } + if !env.OK || env.Data.Status != "SUCCESS" || env.Data.ResultURL != "https://cdn.example.com/video.mp4" || env.Data.LastFrameURL != "https://cdn.example.com/last-frame.png" { + t.Fatalf("unexpected response: %#v", env) + } + if env.Data.Metadata["last_frame_url"] != "https://cdn.example.com/last-frame.png" { + t.Fatalf("unexpected metadata: %#v", env.Data.Metadata) + } +} + func TestResolveVideoReferencesSupportsImagesArray(t *testing.T) { cfg := Config{ SQLitePath: filepath.Join(t.TempDir(), "popiart.db"), @@ -585,6 +822,61 @@ func TestFetchVideoTaskFallsBackToGenericTaskEnvelope(t *testing.T) { } } +func TestFetchVideoTaskPrefersGenericGenerationStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/videos/task_generic_wins": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"task_generic_wins","status":"in_progress","progress":50}`)) + case "/v1/video/generations/task_generic_wins": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":0,"data":{"id":"task_generic_wins","status":"SUCCESS","result_url":"http://example.com/done.mp4","progress":100}}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + client := newNewAPIClient(Config{NewAPIBaseURL: srv.URL}) + result, err := client.fetchVideoTask(context.Background(), "sk-test", "task_generic_wins") + if err != nil { + t.Fatalf("fetchVideoTask: %v", err) + } + if result.Status != "completed" { + t.Fatalf("expected generic SUCCESS to win, got %q", result.Status) + } + if result.URL != "http://example.com/done.mp4" { + t.Fatalf("expected result url to round-trip, got %q", result.URL) + } +} + +func TestFetchVideoTaskMapsJimengDoneNonSuccessCodeToFailure(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/videos/task_jimeng_failed": + http.NotFound(w, r) + case "/v1/video/generations/task_jimeng_failed": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":"success","data":{"task_id":"task_jimeng_failed","status":"done","code":40001,"fail_reason":"Image Decode Error"}}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + client := newNewAPIClient(Config{NewAPIBaseURL: srv.URL}) + result, err := client.fetchVideoTask(context.Background(), "sk-test", "task_jimeng_failed") + if err != nil { + t.Fatalf("fetchVideoTask: %v", err) + } + if result.Status != "failed" { + t.Fatalf("expected failed status, got %q", result.Status) + } + if result.ErrorReason != "Image Decode Error" { + t.Fatalf("expected fail reason to round-trip, got %q", result.ErrorReason) + } +} + func TestExecuteImageToVideoJobCompletesAndArtifactCanBeRead(t *testing.T) { refBytes := tinyPNG(t) videoBytes := []byte("not-a-real-mp4-but-good-enough-for-streaming") @@ -746,6 +1038,107 @@ func TestExecuteImageToVideoJobCompletesAndArtifactCanBeRead(t *testing.T) { } } +func TestExecuteJimengActionTransferJobUsesImagesAndVideos(t *testing.T) { + videoBytes := []byte("jimeng-mp4") + var gotBody map[string]any + + var outputURL string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/video/generations": + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode Jimeng request body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":"success","data":{"task_id":"task_jimeng_exec_123","status":"in_queue"}}`)) + case "/v1/videos/task_jimeng_exec_123": + http.NotFound(w, r) + case "/v1/video/generations/task_jimeng_exec_123": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":"success","data":{"task_id":"task_jimeng_exec_123","status":"done","code":10000,"result_url":"` + outputURL + `","progress":"100%"}}`)) + case "/jimeng-output.mp4": + w.Header().Set("Content-Type", "video/mp4") + _, _ = w.Write(videoBytes) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + outputURL = srv.URL + "/jimeng-output.mp4" + + cfg := Config{ + NewAPIBaseURL: srv.URL, + SQLitePath: filepath.Join(t.TempDir(), "popiart.db"), + SkillhubDir: makeEmptySkillhub(t), + SessionSecret: "test-secret", + } + server, err := NewWithConfig(cfg) + if err != nil { + t.Fatalf("NewWithConfig: %v", err) + } + + token, _, ok, err := server.store.createSession("sk-test-upstream") + if err != nil { + t.Fatalf("createSession: %v", err) + } + if !ok { + t.Fatal("expected session creation to succeed") + } + current, exists, err := server.store.session(token) + if err != nil { + t.Fatalf("load session: %v", err) + } + if !exists { + t.Fatal("expected stored session") + } + + record, _, err := server.store.createJob( + "", + "video.image2video", + "jimeng_dreamactor_m20_gen_video", + routeExecMode("video.image2video"), + map[string]any{ + "images": []any{"https://example.com/face.jpg"}, + "videos": []any{"https://example.com/action.mp4"}, + "metadata": map[string]any{ + "action": "actionGenerate", + }, + }, + "", + "normal", + "", + current, + ) + if err != nil { + t.Fatalf("createJob: %v", err) + } + + server.executeImageToVideoJob(record) + + if gotBody["model"] != "jimeng_dreamactor_m20_gen_video" { + t.Fatalf("unexpected Jimeng model: %#v", gotBody["model"]) + } + images := gotBody["images"].([]any) + videos := gotBody["videos"].([]any) + if images[0] != "https://example.com/face.jpg" || videos[0] != "https://example.com/action.mp4" { + t.Fatalf("unexpected Jimeng media payload: images=%#v videos=%#v", gotBody["images"], gotBody["videos"]) + } + + done, exists, err := server.store.getJob(current.User.ID, record.JobID) + if err != nil { + t.Fatalf("getJob: %v", err) + } + if !exists || done.Status != "done" { + t.Fatalf("expected completed Jimeng job, exists=%v record=%#v", exists, done) + } + if done.NewAPITaskID != "task_jimeng_exec_123" { + t.Fatalf("expected Jimeng task id, got %q", done.NewAPITaskID) + } + if len(done.ArtifactIDs) != 1 { + t.Fatalf("expected one Jimeng artifact, got %#v", done.ArtifactIDs) + } +} + func TestResolveVideoSizeUsesViduResolutionLabels(t *testing.T) { if got := resolveVideoSize("viduq2", map[string]any{"aspect_ratio": "16:9"}, imageEditReference{}); got != "720p" { t.Fatalf("expected vidu 16:9 to map to 720p, got %q", got) diff --git a/web/app/[locale]/billing/page.tsx b/web/app/[locale]/billing/page.tsx new file mode 100644 index 0000000..a9b3f8f --- /dev/null +++ b/web/app/[locale]/billing/page.tsx @@ -0,0 +1,385 @@ +import Link from "next/link"; +import { BillingAutoRefresh, BillingRefreshListener } from "@/components/billing-refresh-listener"; +import { BillingPageState } from "@/components/billing-page-state"; +import { + type BillingCredits, + type BillingInvoices, + type BillingSubscription, + PopiartApiError, + getBillingCredits, + getBillingInvoices, + getBillingSubscription, + getViewerSession, +} from "@/lib/popiart-api"; +import { type Locale } from "@/lib/site-content"; + +export const dynamic = "force-dynamic"; + +function formatNumber(locale: Locale, value: number) { + return new Intl.NumberFormat(locale === "zh" ? "zh-CN" : "en-US").format(value); +} + +function formatError(error: unknown) { + if (error instanceof PopiartApiError) { + return error.message; + } + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +export default async function BillingPage({ + params, + searchParams, +}: { + params: Promise<{ locale: string }>; + searchParams: Promise<{ kind?: string; status?: string; page?: string }>; +}) { + const { locale } = await params; + const { kind, status, page } = await searchParams; + const typedLocale = locale as Locale; + const isZh = typedLocale === "zh"; + const session = await getViewerSession(); + + const title = isZh ? "账单中心" : "Billing"; + const subtitle = isZh + ? "查看当前订阅、积分钱包和订单历史。" + : "Review your active subscription, point wallets, and order history."; + const loginCta = isZh ? "去登录" : "Sign in"; + const consoleCta = isZh ? "前往控制台" : "Open console"; + const unboundTitle = isZh ? "先绑定网关用户" : "Bind a gateway user first"; + const unboundBody = isZh + ? "当前 session 还没有绑定网关用户态,先去控制台绑定后才能读取真实账单与订单。" + : "This session is not bound to a gateway user yet. Bind it in the console first to read live billing and order data."; + const subscriptionTitle = isZh ? "当前订阅" : "Current subscription"; + const creditsTitle = isZh ? "积分钱包" : "Point wallets"; + const invoicesTitle = isZh ? "订单历史" : "Order history"; + const noSubscription = isZh ? "当前没有有效订阅。" : "No active subscription."; + const noCredits = isZh ? "当前没有积分钱包记录。" : "No point wallets."; + const noOrders = isZh ? "当前没有订单记录。" : "No orders yet."; + const kindFilter = kind === "subscription" || kind === "points" ? kind : "all"; + const statusFilter = typeof status === "string" && status.trim() ? status.trim() : "all"; + const currentPage = Number.isFinite(Number(page)) && Number(page) > 0 ? Number(page) : 1; + const pageSize = 8; + const kindLabel = isZh ? "类型" : "Kind"; + const statusLabel = isZh ? "状态" : "Status"; + const allLabel = isZh ? "全部" : "All"; + const subscriptionsLabel = isZh ? "订阅订单" : "Subscription orders"; + const pointsLabel = isZh ? "积分包订单" : "Point orders"; + const prevLabel = isZh ? "上一页" : "Previous"; + const nextLabel = isZh ? "下一页" : "Next"; + const pageLabel = isZh ? "页码" : "Page"; + + if (!session) { + return ( +
{subtitle}
+{subtitle}
+{unboundBody}
+{subtitle}
+{noSubscription}
+ )} +{noCredits}
+ )} +{noOrders}
+ )} ++ {isZh + ? "订单状态已切换为成功。你可以继续回到控制台或账单中心查看最新订阅、积分和订单历史。" + : "The order has completed successfully. Continue to the console or billing center to review the latest subscription, credits, and order history."} +
++ {boundLabel}: {session.gateway_user_id} +
+{billingEmpty}
+ )} +{walletEmpty}
+ )} +{recentOrderEmpty}
+ )} +{usageEmpty}
+ )} +{skillsEmpty}
+ )} +{projectsEmpty}
+ )} +{dictionary.pricing.subtitle}
+ {isZh + ? `到账 ${pack.points_amount + pack.bonus_points} 积分(含赠送 ${pack.bonus_points})` + : `${pack.points_amount + pack.bonus_points} total points (${pack.bonus_points} bonus)`} +
+{labels.body}
+