diff --git a/.agents/README.md b/.agents/README.md index 356724d..8783891 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -1,5 +1,7 @@ # `.agents/` — engineering record of the python-sdk repository +- [Managed Agent input and saved-configuration contract](notes/proposed/feature/2026-11-12-managed-agent-input-contract.md) · [verification](evidence/verify/2026-11-12-managed-agent-input-contract.md). + ## Recent changes — 2026-11-10 - [Owner change index](notes/proposed/process/2026-11-10-recent-change-index.md) · [records verification](evidence/verify/2026-11-10-recent-change-index.md). diff --git a/.agents/evidence/verify/2026-11-12-managed-agent-input-contract.md b/.agents/evidence/verify/2026-11-12-managed-agent-input-contract.md new file mode 100644 index 0000000..5d09fcb --- /dev/null +++ b/.agents/evidence/verify/2026-11-12-managed-agent-input-contract.md @@ -0,0 +1,84 @@ +# Verification: managed Agent input contract + +- Date: 2026-11-12 +- Base revision: a9a572d81847fe99216bf1bb454b91810dacc676 +- Branch: feat/agents-api-contract-20261112 +- Owner issue: https://github.com/Alibaba-Agent-Sandbox/python-sdk/issues/162 +- Delivery tracker: https://github.com/Alibaba-Agent-Sandbox/awesome-templates/issues/309 +- Result: PARTIAL +- Environment: Linux, Python3.11.15; matching local client/server0.4.0a7 source candidates in an isolated virtual environment. No cloud/model credentials loaded by these checks. Local PostgreSQL uses pgserver0.1.4 with psycopg/psycopg-binary3.3.2. + +## What was verified + +The managed Gateway accepts the optional message discriminator, uses supplied saved Agent +IDs without coercion or truthiness fallback, preserves saved-plus-inline overrides, and +distinguishes429/503 error categories. This is a bounded API correction, not the complete +YAML migration, new adapter support or an OpenAI SDK compatibility claim. + +## Commands run + +- `python -m pytest client-sdk/tests/test_managed_agents.py -q -k 'typed_user_message or invalid_typed_input or saved_agent_overrides or explicit_agent_id or managed_error_types'` + before production edits: exit1,10 failed/5 passed/52 deselected. Failures reproduce typed + message rejection, empty/non-string ID fallback/coercion and429/503 category behavior. +- The same command after the correction: exit0,15 passed/52 deselected. +- With `or real_http_typed_message` added: exit0,16 passed/52 deselected. The added case uses + a real local HTTP listener, actual quota admission, and a deliberately failing dependency + to prove safe503 serialization; it is not a live cloud service failure test. +- `make test`: initial exit2 in the Server package,874 passed/1 skipped/1 failed. The failing + `test_pip_returns_structure` found no installed packages because this uv-created environment + lacked pip; Make stopped before the Client package. Installed pip26.1.1 and re-ran that + exact case: exit0,1 passed. No product workaround was added. +- Installed optional pgserver0.1.4 and matching psycopg3.3.2, then parameterized typed-input + and saved-override regressions over SQLite and actual temporary PostgreSQL. The focused + four-case selection passed, exit0; fixtures shut down their temporary databases. +- `make test` after environment preparation: exit0; Server875 passed/1 skipped, Client2483 + passed/27 skipped. These counts are separate package runs, not cloud/model acceptance. +- `make lint`, `make typecheck`, `make format-check`, `make hygiene`: each exit0 for both packages. +- `make ci`: exit0; Server875 passed/1 skipped, Client2473 passed/27 skipped/10 deselected; + hygiene, lint, format, typecheck, both wheel/sdist builds, twine checks, py.typed markers, + clean client-only HTTP and optional-server combined installation passed. This is one + Python3.11 interpreter, not the remote3.10/3.11/3.12 matrix. +- The full runs skipped the packaging modules because hatchling was absent from the test + environment (isolated builds had their own backend). Installed the build's actual + hatchling1.33.0 and ran each `tests/test_packaging.py` separately: Server5 passed, Client7 + passed, both exit0. No full suite was rerun solely to change skip counts. +- `bash scripts/check-repo-health.sh --quiet`: exit0,106 passes/1 existing recommended + release-runbook warning/0 failures, including a final repeat after the result update. +- `make hygiene`, `git diff --check`, and staged `git diff --cached --check`: final exit0. +- `bash .githooks/check-secrets.sh`: exit0 against the ten explicitly staged scoped files; + no sensitive information found. The follow-up instance design is outside this patch. + +## Results + +Input/ID/error regressions and required single-interpreter local gates PASS. Existing saved configuration +overrides were retained; missing/foreign nonempty saved IDs already returned404 before this +change. The newly corrected false-like fallback includes the empty string; its lookup now404. +Unknown input types/images/roles and the original text budget still reject atomically. + +The remaining26 Client skips require an explicitly configured compatible Node engine command, +the legacy CLI sibling fixture location, or Node CLI/provider source/dependency prerequisites. +They are reported as skips rather than passes. The two packaging module skips in the full +runs were resolved by the separate12-test packaging run above. Remote matrix, cloud/model +E2E and publication are independent incomplete gates, so the overall result remains PARTIAL. + +## Cleanup + +Local test HTTP listeners close and threads join in finally. The selected regressions create +only temporary SQLite/PostgreSQL records and fixture directories; no cloud/model resources were created. +This is not an account-wide resource inventory. + +## Problems found + +An initial mutual-exclusion hypothesis was rejected by the current official Create Session +reference before implementation: saved Agent plus inline overrides are supported. The patch +does not introduce that incorrect restriction. Other HTTP error categories were deliberately +not inferred from SDK exception names or a stale local mapping. + +One exploratory pytest command combined both packages and hit their same-named +`tests.conftest` modules (exit4). It was corrected to separate package commands, as the +Makefile does; this was a test invocation error, not a product failure. + +## Not verified + +Real cloud/model E2E, complete YAML migration, official SDK interoperability, remote CI/review, +PR merge and publication have not been completed by this patch. The decision remains proposed. diff --git a/.agents/notes/proposed/feature/2026-11-12-managed-agent-input-contract.md b/.agents/notes/proposed/feature/2026-11-12-managed-agent-input-contract.md new file mode 100644 index 0000000..79483a6 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-11-12-managed-agent-input-contract.md @@ -0,0 +1,71 @@ +# Decision: Preserve the managed Agent input and saved-configuration contract + +Status: proposed + +## Problem + +The managed Gateway rejects a user message containing the optional `type: "message"` +discriminator accepted by the current OpenAI Create Session reference. Explicit false-like +Agent IDs can bypass lookup and fall back to an inline configuration, while other non-string +IDs are coerced to strings. Rate-limit and unavailable-service errors use the same category +as invalid requests. These mismatches affect clients before native execution begins. + +## Decision + +Accept only the optional literal message discriminator without widening the supported +user/input_text subset. Validate an explicitly supplied Agent ID as a string and perform +the existing owner-aware lookup even for an empty string. Preserve saved Agent plus inline +overrides and verify that they create independent Session snapshots. Categorize the directly +documented HTTP429 and503 cases while retaining existing codes, messages and field paths. + +## API Design + +`text_input(value)` accepts the existing string and user-message forms plus optional +`type: "message"`. Unknown types, non-user roles, images and oversized text still fail. +`create_session` requires any supplied `agent_id` to be a string: non-string400; unknown, +foreign, deleted or empty ID404. Supplying `agent_id` and supported inline `agent` fields +remains valid; the latter override only the new Session's configuration. +ManagedError reports `rate_limit_error` for429 and `service_unavailable_error` for503. +Other status categories are unchanged; this is not a claim of full OpenAI compatibility. + +## Alternatives considered + +- Reject `agent_id` together with `agent`: rejected after reading the current official + reference, which explicitly permits per-session overrides. The local API's older rule + is not authority for the managed contract and is outside this change. +- Require nonempty IDs: not inferred from the upstream schema, whose string minimum is0. + Existing lookup gives404 for an empty ID without creating a Session. +- Copy a universal error-category map or expose every local Agent feature: rejected; + the verified authoritative examples and current task only justify this bounded patch. + +## Dependencies + +No new dependency, adapter, schema language, cloud resource or package release. +Authoritative references: [Create Session](https://developers.openai.com/api/reference/resources/beta/subresources/agents/subresources/sessions/methods/create) +and [Error codes](https://developers.openai.com/api/docs/guides/error-codes). +Current managed runtime and template work are independent of the broader YAML migration; +these corrections do not establish migration completion or real model acceptance. +Owner issue: [python-sdk162](https://github.com/Alibaba-Agent-Sandbox/python-sdk/issues/162). +Delivery tracker: [templates309](https://github.com/Alibaba-Agent-Sandbox/awesome-templates/issues/309). + +## Test Strategy + +First run new consumer regressions against unchanged production code to demonstrate the +typed-input, ID-lookup/type and category failures. Exercise all three input entry points, +atomic invalid-input rejection, saved overrides, owner/missing-ID behavior and a real local +HTTP listener's accepted request, quota rejection and safe dependency-error conversion. +Run both package suites, lint, typecheck and repository health/hygiene. Preserve the required +cloud/model E2E gate as pending until the encompassing delivery runs it. + +## Acceptance criteria + +- Typed and untyped supported messages are accepted without allowing new input kinds. +- Invalid input and invalid Agent IDs leave no accepted Session or Submission. +- Saved Agent overrides do not mutate the source Agent or previous Sessions. +- 429/503 response categories are correct and private exception text remains hidden. +- Required local checks and current-head remote gates are reported accurately. +- Real cloud/model validation and publication remain explicit incomplete gates. + +## Evidence + +[Verification](../../../evidence/verify/2026-11-12-managed-agent-input-contract.md). diff --git a/CHANGELOG.md b/CHANGELOG.md index a1db365..a202261 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Fixed + +- Accept the optional `type: "message"` discriminator for managed user-text input, + validate explicitly supplied saved Agent IDs without coercion or inline fallback, + and distinguish rate-limit and unavailable-service error categories. Saved Agent + overrides remain scoped to the new Session. / 托管用户文本输入支持可选的 + `type: "message"`;显式 Agent ID 按字符串校验,不再强转或退回内联配置; + 区分限流与服务不可用错误类别,已保存 Agent 的覆盖仍只作用于新 Session。 + ### Documentation - Synchronize CLI-dependent SDK documentation, HTTP MCP template deployment and source-acquisition error guidance. / 同步 SDK 文档中的 CLI 用法、HTTP MCP 模板部署与源码获取错误指引。 diff --git a/client-sdk/src/agent_sandbox/managed_agents/models.py b/client-sdk/src/agent_sandbox/managed_agents/models.py index d9adfbe..e796c24 100644 --- a/client-sdk/src/agent_sandbox/managed_agents/models.py +++ b/client-sdk/src/agent_sandbox/managed_agents/models.py @@ -60,8 +60,12 @@ def text_input(value: Any) -> str: for message in value: if not isinstance(message, dict): raise ManagedError(400, "unsupported_input", "Expected user text", "input") - reject_unknown(message, {"role", "content"}, "input.") - if message.get("role") != "user" or not isinstance(message.get("content"), list): + reject_unknown(message, {"type", "role", "content"}, "input.") + if ( + message.get("type", "message") != "message" + or message.get("role") != "user" + or not isinstance(message.get("content"), list) + ): raise ManagedError(400, "unsupported_input", "Expected user text", "input") for part in message["content"]: if ( diff --git a/client-sdk/src/agent_sandbox/managed_agents/service.py b/client-sdk/src/agent_sandbox/managed_agents/service.py index 09f12bb..7427cfb 100644 --- a/client-sdk/src/agent_sandbox/managed_agents/service.py +++ b/client-sdk/src/agent_sandbox/managed_agents/service.py @@ -434,6 +434,8 @@ def create_session( ) if "stream" in body and not isinstance(body["stream"], bool): raise ManagedError(400, "invalid_stream", "stream must be boolean", "stream") + if "agent_id" in body and not isinstance(body["agent_id"], str): + raise ManagedError(400, "invalid_agent_id", "agent_id must be a string", "agent_id") with self.store.transaction() as tx: old = self._idem(tx, owner, "create-session", key, body) if old: @@ -442,7 +444,7 @@ def create_session( self.check_admission(tx, self.config.revision) if len(tx.list("session", owner)) >= self.config.sessions["maxLogicalSessionsPerOwner"]: raise ManagedError(429, "session_limit", "The logical Session budget is exhausted") - saved = tx.get(str(body["agent_id"]), owner) if body.get("agent_id") else None + saved = tx.get(body["agent_id"], owner) if "agent_id" in body else None if saved and saved["_kind"] != "agent": raise ManagedError(404, "resource_not_found", "Agent was not found") agent = self._agent(body.get("agent", {}), public(saved) if saved else None) diff --git a/client-sdk/src/agent_sandbox/models/managed_agents.py b/client-sdk/src/agent_sandbox/models/managed_agents.py index fa900a6..b817d10 100644 --- a/client-sdk/src/agent_sandbox/models/managed_agents.py +++ b/client-sdk/src/agent_sandbox/models/managed_agents.py @@ -18,7 +18,10 @@ def body(self) -> dict[str, Any]: """Return the documented error envelope.""" return { "error": { - "type": "invalid_request_error", + "type": { + 429: "rate_limit_error", + 503: "service_unavailable_error", + }.get(self.status, "invalid_request_error"), "code": self.code, "message": self.message, "param": self.param, diff --git a/client-sdk/tests/test_managed_agents.py b/client-sdk/tests/test_managed_agents.py index 99f5689..0b3289e 100644 --- a/client-sdk/tests/test_managed_agents.py +++ b/client-sdk/tests/test_managed_agents.py @@ -164,6 +164,247 @@ def create(service: ManagedService, text: str | None = None, key: str = "") -> d return service.handle("owner", "POST", "/v1/agents/sessions", body, key=key)[1] +@pytest.mark.parametrize("components", ["sqlite", "postgres"], indirect=True) +def test_typed_user_message_reaches_each_managed_input_entry(components: Any) -> None: + service, backend, _, _ = components + message = [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "typed prompt"}], + } + ] + _, initial, initial_headers = service.handle( + "owner", + "POST", + "/v1/agents/sessions", + {"agent": {"model": "fixture-model"}, "input": message}, + ) + submissions = [initial_headers["X-Agent-Sandbox-Submission-Id"]] + for route in ("events", "submissions"): + session = create(service) + path = ( + f"/v1/agents/sessions/{session['id']}/events" + if route == "events" + else f"/v1/agent-sandbox/sessions/{session['id']}/submissions" + ) + body = ( + {"events": [{"type": "agent.session.input.message", "input": message}]} + if route == "events" + else {"input": message} + ) + status, value, headers = service.handle("owner", "POST", path, body, key=route) + assert status == 202 + submissions.append( + headers["X-Agent-Sandbox-Submission-Id"] if route == "events" else value["id"] + ) + with service.store.transaction() as tx: + assert [tx.get(sid, "owner")["_prompt"] for sid in submissions] == ["typed prompt"] * 3 + assert tx.get(initial["id"], "owner")["status"] == "in_progress" + assert backend.created == 0 + + +@pytest.mark.parametrize( + ("input_value", "status"), + [ + ( + [ + { + "type": "function_call", + "role": "user", + "content": [{"type": "input_text", "text": "text"}], + } + ], + 400, + ), + ( + [{"type": None, "role": "user", "content": [{"type": "input_text", "text": "text"}]}], + 400, + ), + ( + [ + { + "type": "message", + "role": "assistant", + "content": [{"type": "input_text", "text": "text"}], + } + ], + 400, + ), + ( + [ + { + "type": "message", + "role": "user", + "content": [ + {"type": "input_image", "image_url": "https://example.com/image.png"} + ], + } + ], + 400, + ), + ( + [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "x" * 262145}], + } + ], + 413, + ), + ], +) +def test_invalid_typed_input_does_not_create_managed_state( + components: Any, input_value: Any, status: int +) -> None: + service = components[0] + with pytest.raises(ManagedError) as caught: + service.handle( + "owner", + "POST", + "/v1/agents/sessions", + {"agent": {"model": "fixture-model"}, "input": input_value}, + ) + assert caught.value.status == status + with service.store.transaction() as tx: + assert not tx.list("session", "owner") + assert not tx.list("submission", "owner") + + +@pytest.mark.parametrize("components", ["sqlite", "postgres"], indirect=True) +def test_saved_agent_overrides_are_session_snapshots_and_unknown_ids_are_not_fallbacks( + components: Any, +) -> None: + service = components[0] + agent = service.handle( + "owner", + "POST", + "/v1/agents", + { + "model": "fixture-model", + "instructions": "saved instructions", + "metadata": {"source": "saved"}, + }, + )[1] + original = service.handle("owner", "POST", "/v1/agents/sessions", {"agent_id": agent["id"]})[1] + changed = service.handle( + "owner", + "POST", + "/v1/agents/sessions", + {"agent_id": agent["id"], "agent": {"instructions": "session override"}}, + )[1] + assert original["agent"]["instructions"] == "saved instructions" + assert changed["agent"]["instructions"] == "session override" + assert changed["agent"]["model"] == "fixture-model" + assert changed["agent"]["metadata"] == {"source": "saved"} + assert service.handle("owner", "GET", "/v1/agents/" + agent["id"])[1] == agent + assert ( + service.handle("owner", "GET", "/v1/agents/sessions/" + original["id"])[1]["agent"] + == original["agent"] + ) + for owner, saved_id in (("owner", "agent_missing"), ("foreign", agent["id"]), ("owner", "")): + with pytest.raises(ManagedError) as caught: + service.handle( + owner, + "POST", + "/v1/agents/sessions", + {"agent_id": saved_id, "agent": {"model": "fixture-model"}}, + ) + assert caught.value.status == 404 + assert len(service.handle("owner", "GET", "/v1/agents/sessions")[1]["data"]) == 2 + + +@pytest.mark.parametrize("agent_id", [None, False, 1, [], {}]) +def test_explicit_agent_id_is_not_coerced_or_ignored(components: Any, agent_id: Any) -> None: + service = components[0] + with pytest.raises(ManagedError) as caught: + service.handle( + "owner", + "POST", + "/v1/agents/sessions", + {"agent_id": agent_id, "agent": {"model": "fixture-model"}}, + ) + assert caught.value.status == 400 and caught.value.param == "agent_id" + assert not service.handle("owner", "GET", "/v1/agents/sessions")[1]["data"] + + +@pytest.mark.parametrize( + ("status", "expected"), + [(400, "invalid_request_error"), (429, "rate_limit_error"), (503, "service_unavailable_error")], +) +def test_managed_error_types_keep_status_specific_categories(status: int, expected: str) -> None: + error = ManagedError(status, "public_code", "A bounded public message", "input") + assert error.status == status + assert error.body() == { + "error": { + "type": expected, + "code": "public_code", + "message": "A bounded public message", + "param": "input", + } + } + + +def test_real_http_typed_message_and_safe_error_categories( + components: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + service = components[0] + service.config.sessions["maxLogicalSessionsPerOwner"] = 1 + token = "contract-test-business-token" + auth = TokenAuthenticator( + { + "tokens": [ + { + "sha256": hashlib.sha256(token.encode()).hexdigest(), + "owner": "owner", + "scopes": ["agents:read", "agents:write"], + } + ] + } + ) + server = make_server(service, auth, port=0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + with httpx.Client( + base_url=f"http://127.0.0.1:{server.server_port}", + headers={"Authorization": "Bearer " + token}, + trust_env=False, + ) as api: + body = { + "agent": {"model": "fixture-model"}, + "input": [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "typed HTTP prompt"}], + } + ], + } + accepted = api.post("/v1/agents/sessions", json=body) + assert accepted.status_code == 201 + assert accepted.headers["X-Agent-Sandbox-Submission-Id"] + limited = api.post("/v1/agents/sessions", json=body) + assert limited.status_code == 429 + assert limited.json()["error"]["type"] == "rate_limit_error" + assert limited.headers["Retry-After"] == "1" + + def unavailable(*args: Any, **kwargs: Any) -> Any: + raise RuntimeError("private-dependency-fixture") + + monkeypatch.setattr(service, "handle", unavailable) + failed = api.get("/v1/models") + assert failed.status_code == 503 + assert failed.json()["error"]["type"] == "service_unavailable_error" + assert failed.json()["error"]["code"] == "service_unavailable" + assert "private-dependency-fixture" not in failed.text + finally: + server.shutdown() + server.server_close() + thread.join(timeout=3) + + def test_create_replay_busy_queue_and_owner_boundaries(components: Any) -> None: service, backend, _, _ = components session = create(service, "first", "initial") diff --git a/docs/en/guide/managed-agents-api.md b/docs/en/guide/managed-agents-api.md index bce0f89..964e249 100644 --- a/docs/en/guide/managed-agents-api.md +++ b/docs/en/guide/managed-agents-api.md @@ -59,6 +59,29 @@ Input during an active turn returns `409 active_turn_not_steerable`. Explicit qu `POST /v1/agent-sandbox/sessions/{id}/submissions`, requires `Idempotency-Key`, and returns `202` with a Submission object. Queuing schedules another turn; it does not steer the active turn. +Input may be a non-empty string or an array of user messages containing `input_text` parts. +The combined text is limited to 256 KiB of UTF-8 bytes. +The message object may include `"type": "message"`: + +```json +{"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"Continue the report."}]}]} +``` + +The optional `type` field requires the updated Gateway runtime. Updating a client alone does +not update a deployed Gateway; string input remains available with earlier deployments. + +To reuse a saved Agent, supply its string `agent_id`. You may also supply supported fields in +`agent` to override that configuration for this new Session; the saved Agent and existing +Sessions keep their previous settings. An unknown or another owner's Agent ID returns `404`, +including an empty ID. Non-string IDs return `400` with `error.param: "agent_id"`. +This follows the saved-configuration override behavior in the +[OpenAI Create session reference](https://developers.openai.com/api/reference/resources/beta/subresources/agents/subresources/sessions/methods/create). + +Quota errors return `429` with `error.type: "rate_limit_error"`; an unavailable application +service returns `503` with `error.type: "service_unavailable_error"`. Use `error.code` and +`error.param` for the project-specific reason and field. Check any existing acceptance receipt +before retrying a request whose result is unknown. + ## Files and events Resource POST requests require `Content-Type: application/json`. File uploads use multipart form data. diff --git a/docs/zh/guide/managed-agents-api.md b/docs/zh/guide/managed-agents-api.md index b9cf894..b6dad2d 100644 --- a/docs/zh/guide/managed-agents-api.md +++ b/docs/zh/guide/managed-agents-api.md @@ -53,6 +53,27 @@ Submission ID 响应头和其 `Location`。相同幂等键与正文返回首次 `POST /v1/agent-sandbox/sessions/{id}/submissions`,必须提供 `Idempotency-Key`,返回 `202` 与 Submission 对象。排队会创建下一轮,不会引导当前执行。 +输入可以是非空字符串,或包含 `input_text` 内容的用户消息数组;合并后的 UTF-8 文本上限为 256 KiB。 +消息对象可以带上 +`"type": "message"`: + +```json +{"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"继续完善报告。"}]}]} +``` + +可选的 `type` 字段需要更新后的 Gateway 运行时。只更新客户端不会更新已部署的 Gateway; +旧部署仍可使用字符串输入。 + +复用已保存的 Agent 时,提供字符串 `agent_id`。同时提供的 `agent` 支持字段只覆盖本次新建 +Session 的配置,不修改已保存的 Agent 或其他 Session。未知或属于其他 owner 的 Agent ID +返回 `404`,空字符串也按未知 ID 处理;非字符串返回 `400`,`error.param` 为 `agent_id`。 +这与 [OpenAI 创建 Session 参考](https://developers.openai.com/api/reference/resources/beta/subresources/agents/subresources/sessions/methods/create) +中的已保存配置覆盖行为一致。 + +额度不足返回 `429`,`error.type` 为 `rate_limit_error`;应用服务暂时不可用返回 `503`, +`error.type` 为 `service_unavailable_error`。具体原因与字段见 `error.code` 和 `error.param`。 +请求结果不确定时,先检查已有接收收据,再判断是否重试。 + ## 文件与事件 资源 POST 请求必须使用 `Content-Type: application/json`;文件上传使用 multipart 表单。