fix(task): let task plugins validate their own requests

Before billing, the gateway walked the whole decoded request of a task
plugin and rejected duration, seconds and count members that were out
of range at any depth, including vendor parameters passed through
metadata. A plugin had to declare duration-auto@1 for the gateway to let
a duration of -1 through.

The gateway no longer reads billing quantities from the request. It
still bounds every usage fact a plugin returns before quota is
calculated: second facts at most 3600, count facts at most 128, token and
credit facts saturated at the quota limit. Each plugin validates its own
request and derives its facts from the request it sends upstream.
duration-auto@1 is still accepted and has no effect.

Kling now bills the duration it sends. metadata.duration, which is the
whole body on native routes, overrides the top-level duration and must be
a whole number of seconds from 1 to 3600. Before, a metadata duration of
10 was sent upstream while 5 seconds were billed.
This commit is contained in:
CaIon committed 2026-10-22 16:23:18 +08:00
1 parent dbe97beb9b
commit db80ac8e0c
11 files changed
+124 -265

No files matched your search

+1 -1
View File
@@ -9,7 +9,7 @@ Mandatory conventions for quota, pricing, pre-consume, settlement, refund, and u
**Billing safety invariants:** Quota/billing code MUST never produce a negative charge (a credit) from arithmetic overflow or unvalidated input. Apply defense in depth:
- Every user-controlled quantity that becomes a billing multiplier (image `n`, video `seconds`/`duration`, resolution/quality ratios, batch counts) MUST be bounded before it reaches quota calculation. Reject out-of-range values at request validation with a 400. Existing bounds: `dto.MaxImageN` for image generation count, `relaycommon.MaxTaskDurationSeconds` for task video duration, `maxTokensLimit` (`relay/helper/valid_request.go`) for `max_tokens`-family fields on every relay format (OpenAI, Claude, Gemini, Responses). Reuse these constants instead of introducing new ad hoc limits for the same concepts. When adding a new relay format or request DTO, bound its max-tokens and count fields in its validator from day one.
- Watch for validation bypass paths: passthrough fields (e.g. `Extra["parameters"]`), task `metadata` maps, and multipart form fields can carry the same quantities around the standard DTO validation. Any adaptor that reads a multiplier from such a path must enforce the same bound (or clamp) locally. For task plugins the host does this: it rejects out-of-range duration and count members at any depth of the decoded request. The one exception is a duration of -1 (the vendor picks the length) for plugins that declare `duration-auto@1`; they must reserve the model's longest duration and settle on the usage the vendor reports.
- Watch for validation bypass paths: passthrough fields (e.g. `Extra["parameters"]`), task `metadata` maps, and multipart form fields can carry the same quantities around the standard DTO validation. Any adaptor that reads a multiplier from such a path must enforce the same bound (or clamp) locally. Task plugins validate their own requests: the host bounds every usage fact a plugin returns before quota calculation and does not interpret request members. A plugin MUST derive its facts from the request it sends upstream, including values merged from `metadata` or other passthrough maps, and MUST bound any request quantity it turns into a token or credit fact; the host caps `second` and `count` facts but only saturates `token` and `credit` facts at the int32 quota boundary. A plugin that forwards a duration of -1 (the vendor picks the length) must reserve the model's longest duration and settle on the usage the vendor reports.
- Durations parsed from media metadata are user/upstream-controlled too: audio file headers (transcription token counting, TTS response duration) and upstream deduction numbers (e.g. Kling `FinalUnitDeduction`) can claim absurd values. Convert them with saturation before they become token counts.
- Never convert a computed quota or token count to `int` with a bare cast like `int(float64(quota) * ratio)`, `int(math.Round(...))` on unbounded input, or `int(decimal.IntPart())`. All quota rounding/conversion is centralized in `common/quota_math.go`; use those helpers: `common.QuotaFromFloat` (truncating) for float products, `common.QuotaRound` (half-away-from-zero) where rounding is intended, and `common.QuotaFromDecimal` for decimal products. `billingexpr.QuotaRound` delegates to `common.QuotaRound`. Do not reintroduce local conversion helpers or bare casts. Single-request saturation stays at the int32 boundary so batch accumulation cannot approach 64-bit wraparound; wallet/top-up conversion uses `common.WalletQuotaFromDecimalStrict` with the JavaScript-safe `common.MaxWalletQuota` boundary. Every clamp/NaN fallback is logged via `common.SysError`.
- Saturation events are also audited: each helper has a `*Checked` variant (`common.QuotaFromFloatChecked` / `QuotaRoundChecked` / `QuotaFromDecimalChecked`) that additionally returns a `*common.QuotaClamp` when clamping occurred. Billing paths that compute a charge capture that clamp onto `relayInfo.QuotaClamp` (or thread it into task settlement) and, right before writing the consume/task log, call `attachQuotaSaturation` (in `service/log_info_generate.go`) which nests the marker under the log's `other.admin_info.quota_saturation` and emits a request-correlated `logger.LogWarn`. Nesting under `admin_info` makes it admin-only for free (non-admin log views strip `admin_info`). When adding a new billing path, use the `*Checked` variant and surface the clamp the same way so the anomaly stays auditable in both the admin log UI and backend logs.
+1 -1
View File
@@ -1,5 +1,5 @@
export type JSONValue = null | boolean | number | string | readonly JSONValue[] | {readonly [key: string]: JSONValue};
/** json-clone@1 and submit-sse-delta@1 are accepted for earlier hosts; new plugins need neither. */
/** json-clone@1, submit-sse-delta@1 and duration-auto@1 are accepted for earlier hosts; new plugins need none of them. */
export type HostCapability = "json-clone@1" | "submit-sse-delta@1" | "json-order@1" | "duration-auto@1";
/** Kinds of upstream a driver can address: the vendor API itself, or another New API gateway with the same plugin installed. */
export type UpstreamKind = "vendor" | "new_api";
+7 -18
View File
@@ -122,6 +122,8 @@ Upgrade the host before installing a plugin that declares it.
`meta.usageExamples` is an optional display-only list of pricing examples. Each `label` is a human-readable spec name. Each `facts` object must be a complete vector over `usageSchema`: every declared key present, no undeclared keys, and values that pass the same validation as usage facts. Plugins whose schema contains a `unit: "token"` number field must declare at least one example. Examples never affect billing. Labels are spec names such as `std · 10s`, not storefront prices.
The host bounds every usage fact before it calculates quota and does not read billing quantities from the request. Numeric facts must be finite and non-negative, `second` facts at most 3600 and `count` facts at most 128; `token` and `credit` facts saturate at 2,147,483,647. Submission facts that break these rules fail the request with 400 before billing. The plugin validates its own request: it derives its facts from the request it sends upstream, including values it merges from `metadata` or other passthrough maps, and bounds any request quantity it turns into a `token` or `credit` fact, because the host only saturates those. A plugin that forwards a duration of `-1`, which some vendors read as "the model picks the length" (Seedance on Volcengine Ark, wan3.0 on Bailian), reserves the longest duration the model can produce and settles the task on the usage the vendor reports.
### Usage profiles by model
`meta.usageProfiles` optionally contains `{models, schema, examples?}` entries.
@@ -233,10 +235,11 @@ supports optional feature detection on hosts that provide this utility; it
does not grant a declared capability. These are additive API v1 extensions;
older strict hosts reject `requiredCapabilities`, so upgrade the host first.
`json-clone@1` and `submit-sse-delta@1` remain accepted for plugins written for
earlier hosts; new plugins need neither. `json-clone@1` has no effect.
`submit-sse-delta@1` still requires `submitResponseTypes` to include `sse` and
the plugin to export `parseSubmitEventDelta`.
`json-clone@1`, `submit-sse-delta@1` and `duration-auto@1` remain accepted for
plugins written for earlier hosts; new plugins need none of them.
`json-clone@1` and `duration-auto@1` have no effect. `submit-sse-delta@1` still
requires `submitResponseTypes` to include `sse` and the plugin to export
`parseSubmitEventDelta`.
`utils.json.clone(value)` returns an independent JSON snapshot
with native mutable JavaScript objects and arrays. It accepts plain objects,
@@ -261,20 +264,6 @@ driver hook call parses the ordered body again, which costs more on large
bodies, so declare the capability only when the upstream needs it. A body
that embeds a file placeholder is still written sorted.
`duration-auto@1` lets a request carry a duration of `-1`, which some vendors
read as "the model picks the length" (Seedance on Volcengine Ark, wan3.0 on
Bailian). Before billing, the host checks every member of the decoded request
at any depth, including vendor parameters passed through `metadata`: a
`duration` or `seconds` member (case, `_` and `-` are ignored, so
`durationSeconds` counts) must be between 0 and 3600. For a plugin that
declares the capability the host also accepts exactly `-1` there; other
negative values and values above the limit are still rejected. Usage facts
are unchanged and must stay non-negative, so the plugin never reports `-1` as
a quantity. A declaring plugin reserves the longest duration the model can
produce when it forwards `-1`, and settles the task on the usage the vendor
reports, so the charge follows the length the model chose. A plugin that
cannot settle on reported usage must not declare it.
Host functions are synchronous and must bound their own work: the JavaScript
execution interrupt cannot preempt a running Go function. Request-scoped data
must remain owned by the host request, not by pooled runtime globals.
+2 -2
View File
@@ -326,8 +326,8 @@ profiles require their own examples, using the same validation as the defaults.
The host resolves model aliases before selecting usage metadata. Model-level
pricing and expression-save APIs use the declared target; an ambiguous alias
with multiple targets in the same plugin retains the plugin defaults. Request
and usage validation use the executing plugin and final upstream model. Polling
with multiple targets in the same plugin retains the plugin defaults. Usage
validation uses the executing plugin and final upstream model. Polling
selects metadata from each saved task's model, including in mixed-model batches.
An upstream name that no profile declares, such as a channel mapping to a vendor
endpoint ID, falls back to the client-facing model's profile. Unmatched models
-8
View File
@@ -132,14 +132,6 @@ func (m Meta) PreservesJSONOrder() bool {
return slices.Contains(m.RequiredCapabilities, CapabilityJSONOrder)
}
// AcceptsAutoDuration reports whether the plugin declared duration-auto@1:
// request validation lets a duration of -1, the vendor's "the model picks the
// length", through to its hooks. The plugin reserves the longest duration the
// model can produce and settles the task on the usage the vendor reports.
func (m Meta) AcceptsAutoDuration() bool {
return slices.Contains(m.RequiredCapabilities, CapabilityDurationAuto)
}
// JSONTextMember names the member of a hook result (a decoded requestBody, a
// request descriptor's body) that the host also takes as JSON text, for a
// plugin that preserves JSON order; it is empty for other plugins.
+1 -1
View File
@@ -323,7 +323,7 @@ func alibabaObject(t *testing.T, value any) map[string]any {
return result
}
// Exercise the production host's recursive usage validation as well as the
// Exercise the production host's usage validation as well as the
// converter, without sending a paid generation request or needing a database.
func submitAlibabaRequest(t *testing.T, plugin *jsplugin.LoadedPlugin, upstream string, request map[string]any) (map[string]any, map[string]any, string) {
t.Helper()
+2 -2
View File
@@ -83,8 +83,8 @@ func TestAlibabaWan3(t *testing.T) {
require.ErrorContains(t, callErr, "only supported by wan3.0")
})
// /v1/video/generations has no plugin decoder, so -1 reaches the host's
// request validation as a vendor parameter; duration-auto@1 lets it through.
// /v1/video/generations has no plugin decoder, so -1 reaches the plugin as
// a vendor parameter in metadata.
t.Run("metadata parameters keep -1 on /v1/video/generations", func(t *testing.T) {
body, facts, _ := submitAlibabaRequest(t, plugin, "wan3.0-video", map[string]any{
"model": "wan3.0-video", "prompt": "a cat", "metadata": map[string]any{"parameters": map[string]any{"duration": -1}},
+74 -1
View File
@@ -1,6 +1,21 @@
package plugins_test
import "testing"
import (
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/pkg/jsplugin"
builtinplugins "github.com/QuantumNous/new-api/plugins"
taskplugin "github.com/QuantumNous/new-api/relay/channel/task/jsplugin"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestKlingResponsesProtocol(t *testing.T) {
testVideoResponsesProtocol(t, videoResponsesTestCase{
@@ -28,4 +43,62 @@ func TestKlingResponsesProtocol(t *testing.T) {
wantUsageKeys: []string{"units"},
wantVendorName: "kling",
})
// Kling bills the duration it sends, and metadata.duration (the whole body
// on native routes) overrides the top-level one, so the plugin bounds that
// value before the host reserves quota.
t.Run("bills the sent duration and rejects out-of-range durations before billing", func(t *testing.T) {
source, err := builtinplugins.Source("kling")
require.NoError(t, err)
plugin, err := jsplugin.NewRegistry().RegisterFactory(source, jsplugin.Options{Key: "kling"})
require.NoError(t, err)
submit := func(t *testing.T, decoder []string, body map[string]any) (*taskplugin.TaskAdaptor, *gin.Context, *relaycommon.RelayInfo, *dto.TaskError) {
t.Helper()
value, err := plugin.Engine.CallPath(t.Context(), decoder[0], decoder[1:], map[string]any{"model": "kling-v1", "body": map[string]any{"kind": "json", "value": body}})
require.NoError(t, err)
info := &relaycommon.RelayInfo{
ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://api-singapore.klingai.com", ApiKey: "ak|sk", UpstreamModelName: "kling-v1"},
OriginModelName: "kling-v1",
TaskRelayInfo: &relaycommon.TaskRelayInfo{PublicTaskID: "task_public"},
}
adaptor := taskplugin.New(plugin)
adaptor.Init(info)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", nil)
c.Set("task_request", alibabaObject(t, value)["requestBody"])
return adaptor, c, info, adaptor.ValidateRequestAndSetAction(c, info)
}
native := []string{"native", "decodeSubmit"}
openaiVideo := []string{"protocols", "openai_video", "decodeRequest"}
for _, tc := range []struct {
name string
decoder []string
body map[string]any
}{
{"native duration above 3600", native, map[string]any{"model_name": "kling-v1", "prompt": "a cat", "duration": 1e12}},
{"native fractional duration", native, map[string]any{"model_name": "kling-v1", "prompt": "a cat", "duration": "5.5"}},
{"metadata duration above 3600 behind valid seconds", openaiVideo, map[string]any{"model": "kling-v1", "prompt": "a cat", "seconds": "5", "metadata": map[string]any{"duration": 3601}}},
} {
t.Run(tc.name, func(t *testing.T) {
_, _, _, taskErr := submit(t, tc.decoder, tc.body)
require.NotNil(t, taskErr)
assert.Equal(t, http.StatusBadRequest, taskErr.StatusCode)
assert.ErrorContains(t, taskErr.Error, "at most 3600")
})
}
adaptor, c, info, taskErr := submit(t, openaiVideo, map[string]any{"model": "kling-v1", "prompt": "a cat", "seconds": "5", "metadata": map[string]any{"duration": "10"}})
require.Nil(t, taskErr)
reader, err := adaptor.BuildRequestBody(c, info)
require.NoError(t, err)
encoded, err := io.ReadAll(reader)
require.NoError(t, err)
var sent map[string]any
require.NoError(t, common.Unmarshal(encoded, &sent))
assert.Equal(t, "10", sent["duration"])
facts, err := adaptor.ExtractUsageFactsValidated(c, info)
require.NoError(t, err)
assert.Equal(t, 2.0, facts["units"], "kling-v1 std costs 0.2 credits per second")
})
}
+7 -6
View File
@@ -165,13 +165,14 @@ function validateKlingDuration(value) {
return n;
}
// The duration Kling receives and the one billed: metadata.duration (the whole
// body on native routes) overrides the top-level duration, which defaults to 5.
function outboundDuration(req) {
const n = Number(req && req.duration);
if (Number.isFinite(n) && n > 0) return n;
const metadata = (req && req.metadata) || {};
const fromMeta = Number(metadata.duration);
if (Number.isFinite(fromMeta) && fromMeta > 0) return fromMeta;
return 5;
let value = metadata.duration;
if (value === undefined || value === null) value = req && req.duration;
if (value === undefined || value === null) return 5;
return validateKlingDuration(value);
}
function outboundMode(req, model) {
@@ -244,7 +245,6 @@ export function buildSubmitRequest(ctx) {
prompt: req.prompt,
image: req.image,
mode: outboundMode(req, model),
duration: String(req.duration || 5),
aspect_ratio: aspectRatio(req.size),
model_name: model,
model: model,
@@ -253,6 +253,7 @@ export function buildSubmitRequest(ctx) {
metadata
);
body.mode = outboundMode({ mode: body.mode, metadata: metadata }, model);
body.duration = String(outboundDuration(req));
if (body.image) body.image = filePlaceholder(body.image);
if (body.image_tail) body.image_tail = filePlaceholder(body.image_tail);
if (!body.prompt) delete body.prompt;
+10 -82
View File
@@ -18,7 +18,6 @@ import (
"reflect"
"regexp"
"slices"
"strconv"
"strings"
"time"
"unicode/utf8"
@@ -161,13 +160,6 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom
return taskErr
}
}
request, hasRequest := c.Get("task_request")
hasUsageProfiles := len(a.plugin.Meta.UsageProfiles) > 0
if hasRequest && !hasUsageProfiles {
if err := a.validateResolvedUsageRequest(request); err != nil {
return service.TaskErrorWrapperLocal(err, "plugin_usage_invalid", http.StatusBadRequest)
}
}
if _, err := a.buildSubmit(c, info); err != nil {
// utils.fetch failures in the build hook name the host fetched: the
// channel base URL or one of the plugin's allowed hosts, which an
@@ -176,14 +168,6 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom
taskErr.LocalError = true
return taskErr
}
// The descriptor may rewrite the model. Validate profiled requests against
// that final model before any quota calculation or upstream submission; an
// upstream endpoint ID without a profile keeps the client model's profile.
if hasRequest && hasUsageProfiles {
if err := a.validateResolvedUsageRequest(request, info.UpstreamModelName, info.OriginModelName); err != nil {
return service.TaskErrorWrapperLocal(err, "plugin_usage_invalid", http.StatusBadRequest)
}
}
return nil
}
@@ -459,7 +443,7 @@ func encodeFilePlaceholder(placeholder map[string]any, form *multipart.Form, lim
header := files[index]
maxBytes := limit
if raw, exists := placeholder["maxBytes"]; exists {
n, ok := usageNumber(raw, false)
n, ok := usageNumber(raw)
if !ok || n <= 0 || n != math.Trunc(n) {
return "", fmt.Errorf("invalid file placeholder")
}
@@ -1689,48 +1673,6 @@ func (a *TaskAdaptor) usageRatios(ctx context.Context, models []string, hook str
return ratios, nil
}
func (a *TaskAdaptor) validateResolvedUsageRequest(request any, models ...string) error {
schema, _ := a.plugin.Meta.UsageForModels(models...)
return a.validateResolvedUsageValue(jsonValue(request), schema)
}
func (a *TaskAdaptor) validateResolvedUsageValue(value any, usageSchema map[string]pluginruntime.UsageFieldSchema) error {
switch typed := value.(type) {
case map[string]any:
for key, item := range typed {
limit, canonical := canonicalUsageLimit(key)
// A duration of -1 asks the vendor to pick the length. Plugins that
// declare duration-auto@1 reserve the longest duration for it and
// settle on the usage the vendor reports; usage facts stay
// non-negative.
if canonical && limit == relaycommon.MaxTaskDurationSeconds && a.plugin.Meta.AcceptsAutoDuration() {
if number, _ := usageNumber(item, true); number == -1 {
continue
}
}
if schema, declared := usageSchema[key]; declared {
if _, err := validateUsageValue(item, schema, true); err != nil {
return err
}
} else if canonical {
if err := validateUsageLimit(item, limit, true); err != nil {
return err
}
}
if err := a.validateResolvedUsageValue(item, usageSchema); err != nil {
return err
}
}
case []any:
for _, item := range typed {
if err := a.validateResolvedUsageValue(item, usageSchema); err != nil {
return err
}
}
}
return nil
}
// validatedUsageRatios returns the normalized facts in a new map because the
// hook result may be a host value, such as the request body, that must stay
// unchanged.
@@ -1741,7 +1683,7 @@ func (a *TaskAdaptor) validatedUsageRatios(facts map[string]any, models ...strin
for key, value := range facts {
validated[key] = value
if schema, declared := usageSchema[key]; declared {
number, err := validateUsageValue(value, schema, false)
number, err := validateUsageValue(value, schema)
if err != nil {
return nil, nil, err
}
@@ -1753,7 +1695,7 @@ func (a *TaskAdaptor) validatedUsageRatios(facts map[string]any, models ...strin
}
continue
}
number, numeric := usageNumber(value, false)
number, numeric := usageNumber(value)
if !numeric {
continue
}
@@ -1790,7 +1732,7 @@ func (a *TaskAdaptor) validatedCompletionUsageFacts(facts any, models ...string)
for key, value := range values {
validated[key] = value
if schema, declared := usageSchema[key]; declared {
number, err := validateUsageValue(value, schema, false)
number, err := validateUsageValue(value, schema)
if err != nil {
return nil, err
}
@@ -1800,7 +1742,7 @@ func (a *TaskAdaptor) validatedCompletionUsageFacts(facts any, models ...string)
continue
}
if limit, canonical := canonicalUsageLimit(key); canonical {
number, numeric := usageNumber(value, false)
number, numeric := usageNumber(value)
if !numeric {
return nil, fmt.Errorf("plugin usage value must be a number")
}
@@ -1812,13 +1754,13 @@ func (a *TaskAdaptor) validatedCompletionUsageFacts(facts any, models ...string)
}
switch key {
case "upstreamUnits", "completionTokens", "totalTokens":
number, numeric := usageNumber(value, false)
number, numeric := usageNumber(value)
if !numeric || math.IsNaN(number) || math.IsInf(number, 0) || number < 0 {
return nil, fmt.Errorf("plugin usage value must be a finite non-negative number")
}
validated[key] = float64(common.QuotaFromFloat(number))
default:
if number, numeric := usageNumber(value, false); numeric {
if number, numeric := usageNumber(value); numeric {
validated[key] = number
}
}
@@ -1826,7 +1768,7 @@ func (a *TaskAdaptor) validatedCompletionUsageFacts(facts any, models ...string)
return validated, nil
}
func validateUsageValue(value any, schema pluginruntime.UsageFieldSchema, allowNumericString bool) (float64, error) {
func validateUsageValue(value any, schema pluginruntime.UsageFieldSchema) (float64, error) {
if len(schema.Enum) > 0 {
text, ok := value.(string)
if !ok {
@@ -1843,7 +1785,7 @@ func validateUsageValue(value any, schema pluginruntime.UsageFieldSchema, allowN
}
return 0, nil
}
number, ok := usageNumber(value, allowNumericString)
number, ok := usageNumber(value)
if !ok {
return 0, fmt.Errorf("plugin usage value must be a number")
}
@@ -1868,14 +1810,6 @@ func validateUsageValue(value any, schema pluginruntime.UsageFieldSchema, allowN
return number, nil
}
func validateUsageLimit(value any, limit int, allowNumericString bool) error {
number, ok := usageNumber(value, allowNumericString)
if !ok {
return fmt.Errorf("plugin usage value must be a number")
}
return validateUsageNumberLimit(number, limit)
}
func validateUsageNumberLimit(number float64, limit int) error {
if math.IsNaN(number) || math.IsInf(number, 0) || number < 0 {
return fmt.Errorf("plugin usage value must be a finite non-negative number")
@@ -1886,7 +1820,7 @@ func validateUsageNumberLimit(number float64, limit int) error {
return nil
}
func usageNumber(value any, allowNumericString bool) (float64, bool) {
func usageNumber(value any) (float64, bool) {
switch number := value.(type) {
case float64:
return number, true
@@ -1894,12 +1828,6 @@ func usageNumber(value any, allowNumericString bool) (float64, bool) {
return float64(number), true
case int:
return float64(number), true
case string:
if !allowNumericString {
return 0, false
}
parsed, err := strconv.ParseFloat(strings.TrimSpace(number), 64)
return parsed, err == nil
default:
return 0, false
}
+19 -143
View File
@@ -459,14 +459,11 @@ func TestTaskAdaptorJSONOrderCapability(t *testing.T) {
source string
client string
want string
status int
}{
{name: "declared keeps the client's order", source: orderedProtocolPlugin, client: client,
want: `{"model":"m","state":{"zeta":"1","alpha":"2","mid":"&<>"},"questions":{"q2":{"type":"noul"},"q1":{"type":"noul"}}}`},
{name: "undeclared stays sorted", source: undeclared, client: client,
want: `{"model":"m","questions":{"q1":{"type":"noul"},"q2":{"type":"noul"}},"state":{"alpha":"2","mid":"\u0026\u003c\u003e","zeta":"1"}}`},
{name: "declared still bounds billing quantities", source: orderedProtocolPlugin,
client: `{"model":"m","state":{"duration":3601},"questions":{}}`, status: http.StatusBadRequest},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -486,14 +483,7 @@ func TestTaskAdaptorJSONOrderCapability(t *testing.T) {
adaptor := New(plugin)
adaptor.Init(info)
taskErr := adaptor.ValidateRequestAndSetAction(c, info)
if tc.status != 0 {
require.NotNil(t, taskErr)
assert.Equal(t, tc.status, taskErr.StatusCode)
assert.Equal(t, "plugin_usage_invalid", taskErr.Code)
return
}
require.Nil(t, taskErr)
require.Nil(t, adaptor.ValidateRequestAndSetAction(c, info))
body, err := adaptor.BuildRequestBody(c, info)
require.NoError(t, err)
raw, err := io.ReadAll(body)
@@ -1243,62 +1233,26 @@ export function extractUsageOnComplete(task, result, body) { return (body || {})
return adaptor, context, info
}
requestTests := []struct {
name string
body map[string]any
}{
{
name: "duration in resolved metadata",
body: map[string]any{"metadata": map[string]any{"duration": relaycommon.MaxTaskDurationSeconds + 1}},
},
{
name: "count in resolved metadata",
body: map[string]any{"metadata": map[string]any{"count": dto.MaxImageN + 1}},
},
{
name: "declared enum in resolved metadata",
body: map[string]any{"metadata": map[string]any{"mode": "turbo"}},
},
{
name: "implicit duration key without declaration",
body: map[string]any{"durationSeconds": relaycommon.MaxTaskDurationSeconds + 1},
},
{
name: "implicit count key without declaration",
body: map[string]any{"image_count": dto.MaxImageN + 1},
},
{
name: "negative resolved duration",
body: map[string]any{"duration": -1},
},
{
name: "non-finite resolved duration",
body: map[string]any{"duration": math.Inf(1)},
},
{
name: "metadata cannot hide behind valid top-level duration",
body: map[string]any{
"duration": relaycommon.MaxTaskDurationSeconds,
"metadata": map[string]any{"duration": relaycommon.MaxTaskDurationSeconds + 1},
t.Run("request members are not read as quantities and usage facts stay bounded", func(t *testing.T) {
// Plugins validate their own requests; members named like a quantity
// or a usage field are vendor parameters or client content.
adaptor, context, info := newRequest(t, map[string]any{
"duration": -1,
"metadata": map[string]any{
"count": dto.MaxImageN + 1,
"mode": "turbo",
"parameters": map[string]any{"durationSeconds": relaycommon.MaxTaskDurationSeconds + 1},
},
},
{
name: "nested passthrough duration",
body: map[string]any{
"metadata": map[string]any{
"parameters": map[string]any{"duration": relaycommon.MaxTaskDurationSeconds + 1},
},
},
},
}
for _, testCase := range requestTests {
t.Run(testCase.name, func(t *testing.T) {
adaptor, context, info := newRequest(t, testCase.body)
taskErr := adaptor.ValidateRequestAndSetAction(context, info)
require.NotNil(t, taskErr)
assert.Equal(t, "plugin_usage_invalid", taskErr.Code)
"state": map[string]any{"options": map[string]any{"y": "Yes", "n": "No"}},
"hookUsageEntries": []any{map[string]any{"name": "duration", "value": -1}},
})
}
require.Nil(t, adaptor.ValidateRequestAndSetAction(context, info))
_, err := adaptor.ExtractUsageFactsValidated(context, info)
assert.Error(t, err)
ratios, err := adaptor.EstimateBillingValidated(context, info)
assert.Error(t, err)
assert.Nil(t, ratios)
})
hookTests := []struct {
name string
@@ -1343,17 +1297,6 @@ export function extractUsageOnComplete(task, result, body) { return (body || {})
})
}
t.Run("numeric strings remain valid in vendor request fields", func(t *testing.T) {
adaptor, context, info := newRequest(t, map[string]any{
"metadata": map[string]any{
"duration": "5",
"count": "2",
"mode": "std",
},
})
assert.Nil(t, adaptor.ValidateRequestAndSetAction(context, info))
})
t.Run("numeric strings from usage hooks are rejected", func(t *testing.T) {
adaptor, context, info := newRequest(t, map[string]any{
"hookUsageEntries": []any{map[string]any{"name": "duration", "value": "5"}},
@@ -1364,53 +1307,6 @@ export function extractUsageOnComplete(task, result, body) { return (body || {})
assert.Nil(t, ratios)
})
t.Run("duration -1 passes only for plugins that declare duration-auto@1", func(t *testing.T) {
declaring, err := pluginruntime.NewRegistry().Register(strings.Replace(source, `fetchMode: "per_task",`, `fetchMode: "per_task", requiredCapabilities: ["duration-auto@1"],`, 1), pluginruntime.Options{})
require.NoError(t, err)
for _, tc := range []struct {
name string
declaring bool
body map[string]any
accepted bool
}{
{"undeclared plugins keep rejecting it", false, map[string]any{"metadata": map[string]any{"duration": -1}}, false},
{"vendor parameters and declared usage fields", true, map[string]any{
"duration": -1,
"metadata": map[string]any{"seconds": "-1", "parameters": map[string]any{"durationSeconds": -1}},
}, true},
{"other negative durations", true, map[string]any{"metadata": map[string]any{"duration": -2}}, false},
{"durations above the host limit", true, map[string]any{"metadata": map[string]any{"duration": relaycommon.MaxTaskDurationSeconds + 1}}, false},
{"negative counts", true, map[string]any{"metadata": map[string]any{"count": -1}}, false},
} {
t.Run(tc.name, func(t *testing.T) {
adaptor, context, info := newRequest(t, tc.body)
if tc.declaring {
adaptor = New(declaring)
adaptor.Init(info)
}
taskErr := adaptor.ValidateRequestAndSetAction(context, info)
if tc.accepted {
assert.Nil(t, taskErr)
return
}
require.NotNil(t, taskErr)
assert.Equal(t, "plugin_usage_invalid", taskErr.Code)
})
}
adaptor, context, info := newRequest(t, map[string]any{
"metadata": map[string]any{"duration": -1},
"hookUsageEntries": []any{map[string]any{"name": "duration", "value": -1}},
})
adaptor = New(declaring)
adaptor.Init(info)
require.Nil(t, adaptor.ValidateRequestAndSetAction(context, info))
_, err = adaptor.ExtractUsageFactsValidated(context, info)
assert.Error(t, err, "usage facts stay non-negative")
_, err = adaptor.EstimateBillingValidated(context, info)
assert.Error(t, err, "billing ratios stay non-negative")
})
t.Run("declared token facts use int32 saturation instead of duration cap", func(t *testing.T) {
adaptor, context, info := newRequest(t, map[string]any{
"hookUsageEntries": []any{
@@ -1441,9 +1337,6 @@ export function extractUsageOnComplete(task, result, body) { return (body || {})
t.Run("canonical maxima and enum are accepted", func(t *testing.T) {
adaptor, context, info := newRequest(t, map[string]any{
"duration": relaycommon.MaxTaskDurationSeconds,
"count": dto.MaxImageN,
"mode": "std",
"hookUsageEntries": []any{
map[string]any{"name": "duration", "value": float64(relaycommon.MaxTaskDurationSeconds)},
map[string]any{"name": "count", "value": float64(dto.MaxImageN)},
@@ -2165,23 +2058,6 @@ export function extractUsageOnComplete(ctx,result,body){return body.usage;}
})
}
t.Run("request revalidated after model rewrite", func(t *testing.T) {
adaptor, c, info := newRequest(t, "public-alias", "video", map[string]any{
"rewriteTo": "image", "metadata": map[string]any{"units": dto.MaxImageN + 1},
})
taskErr := adaptor.ValidateRequestAndSetAction(c, info)
require.NotNil(t, taskErr)
assert.Equal(t, "plugin_usage_invalid", taskErr.Code)
})
t.Run("request enum belongs to the rewritten model", func(t *testing.T) {
adaptor, c, info := newRequest(t, "public-alias", "video", map[string]any{
"rewriteTo": "image", "mode": "image", "units": 2,
})
require.Nil(t, adaptor.ValidateRequestAndSetAction(c, info))
assert.Equal(t, "image", info.UpstreamModelName)
})
t.Run("completion selects each persisted model including legacy origin fallback", func(t *testing.T) {
adaptor, _, _ := newRequest(t, "public-alias", "image", map[string]any{})
for _, tc := range []struct {