Skip to content

[Provider compatibility] volcengine-coding-plan: response.incomplete relays non-canonical incomplete_details.reason ("length") #7082

Closed

Description

@geekxm

Client or integration

OpenAI-compatible SDK

Provider or upstream service

volcengine-coding-plan (Volcengine Ark, https://ark.cn-beijing.volces.com/api/coding/v3)

OpenCodex version

2.84.0

Endpoint or capability

/v1/responses — incomplete_details.reason on response.incomplete

Current behaviour

When an upstream truncates a turn, the proxy forwards the upstream's
incomplete_details.reason verbatim. Volcengine Ark sends the non-canonical
value "length", so the proxy emits:

{ "type": "response.incomplete",
  "response": { "status": "incomplete",
                "incomplete_details": { "reason": "length" } } }

"length" is not a legal value of the Responses
incomplete_details.reason enum, so OpenAI-conformant clients reject the turn
as an error instead of treating it as a normal length stop.

Concretely, @earendil-works/pi-ai maps the spec values and errors on anything else:

case "incomplete":
  if (incompleteReason === "max_output_tokens") return { stopReason: "length" };
  return { stopReason: "error",
           errorMessage: `Response incomplete: ${incompleteReason}` };

so the user-visible failure is a hard turn error:

Response incomplete: length

Reported from the DeepSeek Harness client, which surfaces it as
PI_AI_ERROR.

Why this looks like an OpenCodex-side inconsistency — the proxy already
owns the correct mapping, it just does not apply it on this path:

  • src/responses/truncated-stop-reason.ts:22-27 maps "length" →
    "max_output_tokens".
  • That mapping is used on the done/stopReason path
    (src/bridge/sse.ts:1257).
  • The response.incomplete passthrough path does not use it:
    src/adapters/openai-responses/passthrough.ts:207-208 extracts the upstream
    reason verbatim, passthrough.ts:659-660 emits it as the internal
    incomplete event's reason, and src/bridge/sse.ts:1294 writes
    reason: event.reason straight into the outbound payload.

So two truncation paths inside the same proxy disagree: one normalizes,
the other passes through.

Expected behaviour

response.incomplete should carry a canonical
incomplete_details.reason. For an upstream truncation that should be
"max_output_tokens", i.e. the same normalization the done path already
performs via truncationReasonFor().

A defensive normalization on the passthrough path would also cover other
non-canonical upstream vocabularies (Anthropic refusal,
model_context_window_exceeded, Gemini safety, …) that
TRUNCATED_STOP_REASONS already knows about.

Minimal redacted request or reproduction

Any truncating request through a volcengine-coding-plan provider. Minimal
form (key redacted):

curl -sS -N -X POST \
  'https://127.0.0.1:10100/v1/responses' \
  -H 'Authorization: Bearer <redacted>' \
  -H 'Content-Type: application/json' \
  -H 'Accept: text/event-stream' \
  -d '{
        "model": "volcengine-coding-plan/deepseek-v4-flash",
        "input": [{"role":"user","content":"Write a 2000-word essay about the ocean."}],
        "stream": true,
        "store": false,
        "max_output_tokens": 64
      }'

max_output_tokens is set deliberately low to force truncation; the same
happens naturally on any normal long turn that reaches the model's output cap.

The upstream is non-conformant too — a direct call to
https://ark.cn-beijing.volces.com/api/coding/v3/responses with the same body
returns incomplete_details: {"reason": "length"} in its own SSE, so the proxy
is faithfully relaying a bad value rather than inventing it.

Actual response or error

Proxy output (final event):

event: response.incomplete
data: {"type":"response.incomplete","response":{"status":"incomplete",
       "incomplete_details":{"reason":"length"},"usage":{...,"output_tokens":64}}}

Grep-friendly:

$ curl ... | grep -o '"incomplete_details":{[^}]*}'
"incomplete_details":{"reason":"length"}

Upstream documentation

Suggested mapping or implementation notes

Normalize on the passthrough path as well, so both truncation routes agree:

  • In src/adapters/openai-responses/passthrough.ts, when building the
    { type: "incomplete", reason } event (lines ~659-660 and ~753-754), run the
    extracted reason through the existing
    truncationReasonFor() / TRUNCATED_STOP_REASONS table and emit
    "max_output_tokens" / "content_filter".
  • Alternatively normalize once at the emit site in src/bridge/sse.ts:1294
    (reason: truncationReasonFor(event.reason) ?? event.reason), which also
    covers adapters that are not the openai-responses passthrough.
  • Keeping an unrecognized reason as-is (rather than forcing "content_filter")
    seems right, since truncationReasonFor deliberately does not classify
    unknown values as truncation.

Additional context and attachments

  • Observed while routing a volcengine-coding-plan provider through OpenCodex
    2.84.0 on macOS; the client is an OpenAI-Responses-compatible SDK, not
    codex-rs, so no Codex-side workaround is available.
  • Workaround currently in use on the client side: declare an explicit
    max_output_tokens for every routed model, which reduces (but does not
    eliminate) how often the upstream truncates.
  • Related reading in-tree: src/responses/truncated-stop-reason.ts already
    documents this exact class of adapter vocabulary drift (#422), and it lists
    "length" explicitly — which is what makes the passthrough gap surprising.
  • I searched existing provider and compatibility issues.
  • The request and response were redacted.
  • The expected behaviour is based on an upstream specification or a concrete client requirement.

Activity

  1. added
    providerProvider adapters, OpenAI-compat presets, upstream API quirks
    streamingSSE, WebSocket, terminal stream frames
    on Oct 13, 2026
  2. Ingwannu commented on Oct 16, 2026

    @Ingwannu
    Owner

    The passthrough incomplete-reason normalization is implemented in #7122 and carried into #7218. The latter also carries the separate terminal-refusal fix from #7152; normalizing a reason alone must not be presented as solving client retry behavior.

    The carry is still open at the inspected snapshot. Keeping this issue open until dev contains the accepted change. Existing verification is in those PRs; no live Volcengine request was made in this pass.

  3. Ingwannu commented on Oct 16, 2026

    @Ingwannu
    Owner

    The previously pending integration is now on dev: #7218, merge commit 3785915d780fe3b8b13c551c253cb478c504840e. I verified ancestry against dev@c03eafd46b; the carry contains the fix linked in the earlier triage comment.

    Closing as completed on dev. The bundle contains its own verification and documented limits; I did not rerun those runtime tests or confirm the reporter's live client. This does not assert release/package availability.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    providerProvider adapters, OpenAI-compat presets, upstream API quirksprovider-compatibilityProvider compatibility reportsstreamingSSE, WebSocket, terminal stream frames

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions