Commit 9bf328d9 by CaIon

fix: preserve provider fields in Sora video queries

parent ea7cb0ba
......@@ -54,11 +54,15 @@ query is redacted before request logging.
Deployment boundaries and concurrency environment variables are documented in
[v1.md](./v1.md#generic-task-management-api).
The host treats `protocols.openai_video.render` as a standard DTO, not an arbitrary
JSON passthrough. Unknown top-level fields and legacy `task_id` are removed,
`id` is forced to the public task id, and case-insensitive `url` entries are
removed from metadata. Provider output URLs belong only behind artifact
capabilities.
The host requires `protocols.openai_video.render` to return a JSON object and
preserves provider extensions, including output URLs and metadata. Plugins may
return `task.data` to expose the latest persisted upstream snapshot. The host
still overwrites `id`, `object`, `model`, `status`, `progress`, `created_at`, and
`completed_at`, and removes legacy `task_id`. This is field-level projection,
not byte-for-byte HTTP forwarding: known private task IDs are replaced before
rendering, polling may redact inline video data, and retrieval reads the latest
saved snapshot rather than issuing a new upstream request. Artifact content
endpoints remain available for proxied downloads.
Provider-authenticated content URLs must use the channel base host or a
plugin-declared `meta.allowedHosts` entry. A public dynamic CDN URL may instead
......
......@@ -180,7 +180,7 @@ The host registry owns these bindings:
`background: true` on create returns the pending Response immediately (host-synthesized, no plugin hook). Retrieval renders via `renderFinal` only at terminal status. Plugins declaring `"background"` need no create-time hook; the deliverable is rendered at retrieval via `renderFinal`. Plugins cannot observe the `background` field. Unlike the upstream OpenAI API, a plugin that does not declare `"stream"` rejects `stream: true` with a 400; the error names the supported forms.
Protocol media uses host-injected `ctx.artifacts[key].url`. Provider URLs from `task.data` are not protocol output. OpenAI Video projections are DTO-whitelisted and the host overwrites identity, lifecycle, timestamps, and removes URL-like metadata.
Responses protocol media uses host-injected `ctx.artifacts[key].url`. OpenAI Video renderers may return provider fields and URLs from `task.data`; the host preserves those extensions and metadata, but overwrites `id`, `object`, `model`, `status`, `progress`, `created_at`, and `completed_at` and removes legacy `task_id`. Renderers must return a JSON object. Returning `task.data` exposes the latest persisted upstream snapshot, subject to the host's task-ID replacement and inline-video redaction; it does not perform a fresh upstream query or forward HTTP headers/status codes.
## Persisted data and driver hooks
......
......@@ -7,7 +7,7 @@ export const meta = {
en: "OpenAI Sora video generation (text-to-video, image-to-video, and remix)",
zh: "OpenAI Sora 视频生成(文生视频、图生视频、remix)",
},
version: "1.0.2",
version: "1.0.3",
channelTypes: [55, 1], // OpenAI-type channels natively serve sora with the same wire format
author: { name: "QuantumNous" },
models: ["sora-2", "sora-2-pro"],
......@@ -309,6 +309,7 @@ protocols.openai_video = {
};
},
render: function (ctx, task) {
if (task.data && typeof task.data === "object" && !Array.isArray(task.data)) return task.data;
return legacyRenderers.openai_video(task);
},
};
......@@ -805,26 +805,24 @@ func (a *TaskAdaptor) ConvertToOpenAIVideo(task *model.Task) ([]byte, error) {
if err != nil {
return nil, err
}
rendered := kitdto.NewOpenAIVideo()
if err = common.Unmarshal(encoded, rendered); err != nil {
var rendered map[string]any
if err = common.Unmarshal(encoded, &rendered); err != nil || rendered == nil {
return nil, fmt.Errorf("plugin returned an invalid OpenAI video object")
}
// Keep provider extensions intact while the host owns the public task's
// identity and lifecycle, including completion timestamps after settlement.
host := task.ToOpenAIVideo()
rendered.ID = host.ID
rendered.Object = host.Object
rendered.TaskID = ""
rendered.Status = host.Status
rendered.Progress = host.Progress
rendered.CreatedAt = host.CreatedAt
rendered.Model = host.Model
rendered.CompletedAt = host.CompletedAt
for key := range rendered.Metadata {
if strings.EqualFold(key, "url") {
delete(rendered.Metadata, key)
}
}
if len(rendered.Metadata) == 0 {
rendered.Metadata = nil
rendered["id"] = host.ID
rendered["object"] = host.Object
delete(rendered, "task_id")
rendered["status"] = host.Status
rendered["progress"] = host.Progress
rendered["created_at"] = host.CreatedAt
rendered["model"] = host.Model
if host.CompletedAt != 0 {
rendered["completed_at"] = host.CompletedAt
} else {
delete(rendered, "completed_at")
}
return common.Marshal(rendered)
}
......
......@@ -17,6 +17,7 @@ import (
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/plugins"
"github.com/QuantumNous/new-api/relay/channel"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
......@@ -532,69 +533,90 @@ func TestTaskAdaptorMapsJSContract(t *testing.T) {
require.NoError(t, err)
}
func TestTaskAdaptorSanitizesOpenAIVideoRendererOutput(t *testing.T) {
source := `
export const meta = {
apiVersion: 1, key: "safe-video", name: "Safe Video", version: "1.0.0",
author: {name: "Test"}, models: ["model"], fetchMode: "per_task", protocols: ["openai_video"],
};
export function buildSubmitRequest(ctx) { return {url: ctx.baseUrl + "/submit"}; }
export function parseSubmitResponse() { return {taskId: "upstream"}; }
export function buildQueryRequest(ctx) { return {url: ctx.baseUrl + "/query"}; }
export function parseTaskResult() { return {status: "SUCCESS"}; }
export function listArtifacts() { return []; }
export function buildContentRequest() { throw new Error("artifact_not_found"); }
export const protocols = {openai_video: {
decodeRequest: function(ctx) { return {kind:"submit", model:ctx.model, requestBody:ctx.body.value}; },
render: function() {
return {
id: "upstream-id",
task_id: "upstream-task-id",
object: "provider-video",
model: "model",
status: "completed",
progress: 100,
created_at: 10,
completed_at: 20,
metadata: {
url: "https://upstream.example/video.mp4",
URL: "https://upstream.example/uppercase.mp4",
label: "safe",
},
url: "https://upstream.example/top-level.mp4",
upstream_url: "https://upstream.example/unknown.mp4",
provider_payload: {task_id: "upstream-task-id"},
};
}}};
`
func TestTaskAdaptorPreservesSoraVideoResponseFields(t *testing.T) {
source, err := plugins.Source("sora")
require.NoError(t, err)
plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
require.NoError(t, err)
adaptor := New(plugin)
rendered, err := adaptor.ConvertToOpenAIVideo(&model.Task{
TaskID: "task_public",
Status: model.TaskStatusInProgress,
Properties: model.Properties{OriginModelName: "origin-model"},
})
require.NoError(t, err)
for _, tc := range []struct {
status model.TaskStatus
want string
}{
{model.TaskStatusInProgress, "in_progress"},
{model.TaskStatusSuccess, "completed"},
{model.TaskStatusFailure, "failed"},
} {
t.Run(string(tc.status), func(t *testing.T) {
task := &model.Task{
TaskID: "task_public",
Status: tc.status,
Progress: "42%",
CreatedAt: 100,
FinishTime: 200,
Properties: model.Properties{OriginModelName: "origin-model"},
PrivateData: model.TaskPrivateData{UpstreamTaskID: "upstream-task-id"},
Data: []byte(`{
"id":"upstream-task-id","task_id":"upstream-task-id",
"object":"provider-video","model":"provider-model",
"status":"completed","progress":100,"created_at":10,"completed_at":20,
"url":"https://cdn.example/video.mp4",
"metadata":{"url":"https://cdn.example/video.mp4","URL":"https://cdn.example/uppercase.mp4"},
"provider_payload":{"task_id":"upstream-task-id","items":[{"enabled":false,"count":0,"value":null}]},
"seconds":8,"resolution":"720p","aspect_ratio":"16:9",
"reference_images":["https://cdn.example/reference.png"],
"error":{"code":"provider_error","message":"provider rejected request","detail":{"retryable":false}}
}`),
}
rendered, err := adaptor.ConvertToOpenAIVideo(task)
require.NoError(t, err)
var video dto.OpenAIVideo
require.NoError(t, common.Unmarshal(rendered, &video))
assert.Equal(t, "task_public", video.ID)
assert.Equal(t, "video", video.Object)
assert.Empty(t, video.TaskID)
assert.Equal(t, "origin-model", video.Model)
assert.Zero(t, video.CompletedAt)
assert.Equal(t, map[string]any{"label": "safe"}, video.Metadata)
var fields map[string]any
require.NoError(t, common.Unmarshal(rendered, &fields))
assert.NotContains(t, fields, "url")
assert.NotContains(t, fields, "upstream_url")
assert.NotContains(t, fields, "provider_payload")
assert.NotContains(t, fields, "completed_at")
assert.NotContains(t, string(rendered), "upstream.example")
assert.NotContains(t, string(rendered), "upstream-task-id")
var fields map[string]any
require.NoError(t, common.Unmarshal(rendered, &fields))
assert.Equal(t, "task_public", fields["id"])
assert.NotContains(t, fields, "task_id")
assert.Equal(t, "video", fields["object"])
assert.Equal(t, "origin-model", fields["model"])
assert.Equal(t, tc.want, fields["status"])
assert.Equal(t, float64(42), fields["progress"])
assert.Equal(t, float64(100), fields["created_at"])
if tc.status == model.TaskStatusSuccess {
assert.Equal(t, float64(200), fields["completed_at"])
} else {
assert.NotContains(t, fields, "completed_at")
}
assert.Equal(t, "https://cdn.example/video.mp4", fields["url"])
assert.Equal(t, map[string]any{
"url": "https://cdn.example/video.mp4",
"URL": "https://cdn.example/uppercase.mp4",
}, fields["metadata"])
assert.Equal(t, map[string]any{
"task_id": "task_public",
"items": []any{map[string]any{"enabled": false, "count": float64(0), "value": nil}},
}, fields["provider_payload"])
assert.Equal(t, float64(8), fields["seconds"])
assert.Equal(t, "720p", fields["resolution"])
assert.Equal(t, "16:9", fields["aspect_ratio"])
assert.Equal(t, []any{"https://cdn.example/reference.png"}, fields["reference_images"])
assert.Equal(t, map[string]any{
"code": "provider_error", "message": "provider rejected request",
"detail": map[string]any{"retryable": false},
}, fields["error"])
})
}
}
func TestTaskAdaptorRejectsNonObjectOpenAIVideoRendererOutput(t *testing.T) {
for _, value := range []string{"null", "[]", `"video"`, "42", "false"} {
t.Run(value, func(t *testing.T) {
source := strings.Replace(mockPlugin, `return {id: task.task_id, status: "completed"};`, "return "+value+";", 1)
plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
require.NoError(t, err)
_, err = New(plugin).ConvertToOpenAIVideo(&model.Task{TaskID: "task_public"})
require.ErrorContains(t, err, "invalid OpenAI video object")
})
}
}
func TestTaskAdaptorPreservesOpenAIVideoFailureSlotsAndOwnsLifecycle(t *testing.T) {
......
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