Commit aece11d2 by 星云猫 Committed by GitHub

feat(plugin): add MiniMax-H3 /v2 video generation to the hailuo task … (#7168)

* feat(plugin): add MiniMax-H3 /v2 video generation to the hailuo task plugin

MiniMax-H3 speaks a different contract from the other Hailuo models, so the
hailuo task plugin now branches on the upstream model instead of adding a Go
adaptor:

- submit builds /v2/video_generation with a multimodal `content` array
  (text, first/last frame images, reference video/audio, or a full
  `metadata.content` passthrough), an explicit `ratio`, and 768P/2K
  resolutions; `metadata.callback_url` and `metadata.aigc_watermark` pass
  through
- query uses /v2/query/video_generation/{task_id} and parses the
  `{"task": {...}}` envelope, falling back to the /v1 shapes for every other
  model
- the /v2 result is a public CDN URL, so its artifact is proxied
  credentialless instead of through /v1/files/download
- request bounds (duration 4-15, resolution 768P/2K, ratio whitelist, at most
  2 frame images and 9/3/3 reference images/videos/audios) are enforced while
  the request body is built, which the host runs during validation, so an
  out-of-range duration is rejected with a 400 before it can become a billing
  multiplier
- duration and resolution are reported as usage facts only. Like the rest of
  this plugin, extractUsage returns no billing ratios, so per-call pricing is
  flat and 2K/duration pricing is expressed through the model's tiered billing
  expression over those facts.

Query hooks are driver hooks and are documented to receive `ctx.model` and
`ctx.upstreamModel`, but polling has no relay info and never populated them.
The polling and realtime-fetch call sites now carry the persisted task model
properties and the plugin adaptor maps them onto the query context, with
`upstreamModel` falling back to the origin name for tasks submitted without a
channel mapping.

* fix(plugin): validate Hailuo H3 requests and errors
parent bbd97446
...@@ -139,4 +139,4 @@ Protocol media uses host-injected `ctx.artifacts[key].url`. Provider URLs from ` ...@@ -139,4 +139,4 @@ Protocol media uses host-injected `ctx.artifacts[key].url`. Provider URLs from `
The persisted field remains `task.data`; there is no `task.raw` alias. Driver hooks (`buildSubmitRequest`, `parseSubmitResponse`, query/result, usage, artifact, and content hooks) stay flat and must not branch on the client path or protocol. The persisted field remains `task.data`; there is no `task.raw` alias. Driver hooks (`buildSubmitRequest`, `parseSubmitResponse`, query/result, usage, artifact, and content hooks) stay flat and must not branch on the client path or protocol.
`ctx.model` is the billing and display identity (the origin name the client sent, including a channel-mapping alias). `ctx.upstreamModel` is the machine identity after channel `model_mapping`. Rate tables and model-keyed usage facts must use `ctx.upstreamModel || ctx.model`. Decode and render hooks that echo the client model must keep `ctx.model`. `buildSubmitRequest` must not set descriptor top-level `model` on a mapped pin; the host requires the plugin to echo the alias verbatim. `ctx.model` is the billing and display identity (the origin name the client sent, including a channel-mapping alias). `ctx.upstreamModel` is the machine identity after channel `model_mapping`. Rate tables and model-keyed usage facts must use `ctx.upstreamModel || ctx.model`. Decode and render hooks that echo the client model must keep `ctx.model`. `buildSubmitRequest` must not set descriptor top-level `model` on a mapped pin; the host requires the plugin to echo the alias verbatim. Background polling has no relay info, so query hooks receive both identities from the persisted task properties, and `ctx.upstreamModel` falls back to `ctx.model` when the task was submitted without a channel mapping.
...@@ -528,6 +528,16 @@ func (a *TaskAdaptor) FetchBatchTasks(baseURL, key string, taskIDs []string, pro ...@@ -528,6 +528,16 @@ func (a *TaskAdaptor) FetchBatchTasks(baseURL, key string, taskIDs []string, pro
func (a *TaskAdaptor) FetchTask(baseURL, key string, body map[string]any, proxy string) (*http.Response, error) { func (a *TaskAdaptor) FetchTask(baseURL, key string, body map[string]any, proxy string) (*http.Response, error) {
ctx := map[string]any{"taskId": body["task_id"], "action": body["action"], "requestBody": body, "baseUrl": baseURL} ctx := map[string]any{"taskId": body["task_id"], "action": body["action"], "requestBody": body, "baseUrl": baseURL}
// Query hooks are driver hooks and must see the same model identities as
// submit hooks. Polling has no relay info, so they arrive with the
// persisted task properties the caller puts in the fetch body.
originModel, _ := body["model"].(string)
upstreamModel, _ := body["upstream_model"].(string)
if upstreamModel == "" {
upstreamModel = originModel
}
ctx["model"] = originModel
ctx["upstreamModel"] = upstreamModel
auth, err := resolveAuth(a.plugin.Meta.Auth, key, proxy) auth, err := resolveAuth(a.plugin.Meta.Auth, key, proxy)
if err != nil { if err != nil {
return nil, err return nil, err
......
...@@ -1208,3 +1208,51 @@ func TestTaskAdaptorBuildSubmitReceivesMappedUpstreamModel(t *testing.T) { ...@@ -1208,3 +1208,51 @@ func TestTaskAdaptorBuildSubmitReceivesMappedUpstreamModel(t *testing.T) {
assert.Equal(t, "declared-model", decoded["upstreamModel"]) assert.Equal(t, "declared-model", decoded["upstreamModel"])
assert.Equal(t, "declared-model", decoded["model"]) assert.Equal(t, "declared-model", decoded["model"])
} }
// Polling has no relay info, so query hooks can only branch on the model when
// the host forwards the persisted task identities from the fetch body.
func TestTaskAdaptorFetchTaskExposesModelIdentities(t *testing.T) {
service.InitHttpClient()
var requested string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requested = r.URL.RequestURI()
_, _ = w.Write([]byte(`{}`))
}))
defer server.Close()
source := `
export const meta = {apiVersion:1,key:"query-model",name:"Query Model",version:"1.0.0",author:{name:"Test"},models:["alias"],fetchMode:"per_task"};
export function buildSubmitRequest(ctx){return {url:ctx.baseUrl+"/submit"}}
export function parseSubmitResponse(){return {taskId:"1"}}
export function buildQueryRequest(ctx){return {url:ctx.baseUrl+"/tasks/"+ctx.model+"/"+ctx.upstreamModel+"/"+ctx.taskId,method:"GET"}}
export function parseTaskResult(){return {status:"SUCCESS"}}
`
plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
require.NoError(t, err)
adaptor := New(plugin)
testCases := []struct {
name string
body map[string]any
want string
}{
{
name: "mapped model",
body: map[string]any{"task_id": "t1", "model": "alias", "upstream_model": "declared-model"},
want: "/tasks/alias/declared-model/t1",
},
{
name: "unmapped model falls back to the origin name",
body: map[string]any{"task_id": "t1", "model": "alias"},
want: "/tasks/alias/alias/t1",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
resp, fetchErr := adaptor.FetchTask(server.URL, "secret", testCase.body, "")
require.NoError(t, fetchErr)
require.NoError(t, resp.Body.Close())
assert.Equal(t, testCase.want, requested)
})
}
}
...@@ -520,6 +520,8 @@ func tryRealtimeFetch(task *model.Task, isOpenAIVideoAPI bool) []byte { ...@@ -520,6 +520,8 @@ func tryRealtimeFetch(task *model.Task, isOpenAIVideoAPI bool) []byte {
resp, err := adaptor.FetchTask(baseURL, channelModel.Key, map[string]any{ resp, err := adaptor.FetchTask(baseURL, channelModel.Key, map[string]any{
"task_id": task.GetUpstreamTaskID(), "task_id": task.GetUpstreamTaskID(),
"action": constant.NormalizeTaskAction(task.Action), "action": constant.NormalizeTaskAction(task.Action),
"model": task.Properties.OriginModelName,
"upstream_model": task.Properties.UpstreamModelName,
}, proxy) }, proxy)
if err != nil || resp == nil { if err != nil || resp == nil {
return nil return nil
......
...@@ -450,6 +450,8 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * ...@@ -450,6 +450,8 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch *
resp, err := adaptor.FetchTask(baseURL, key, map[string]any{ resp, err := adaptor.FetchTask(baseURL, key, map[string]any{
"task_id": task.GetUpstreamTaskID(), "task_id": task.GetUpstreamTaskID(),
"action": constant.NormalizeTaskAction(task.Action), "action": constant.NormalizeTaskAction(task.Action),
"model": task.Properties.OriginModelName,
"upstream_model": task.Properties.UpstreamModelName,
}, proxy) }, proxy)
if err != nil { if err != nil {
return fmt.Errorf("fetchTask failed for task %s: %w", taskId, err) return fmt.Errorf("fetchTask failed for task %s: %w", taskId, err)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment