Skip to content

[Bug]: IdC / Enterprise accounts fail 100% of requests — upstream never emits metadataEvent, so stopReason is always absent #147

Description

@yunfeilu92

Describe the bug / 问题描述

IAM Identity Center (Enterprise) accounts fail 100% of requests since #145 + #146. Every request returns:

{"error":{"message":"upstream truncated response without stop reason","type":"api_error"}}

Root cause: the IdC / Enterprise backend never emits metadataEvent at all, so stopReason is always absent. A complete, successful turn on this profile looks like:

assistantResponseEvent × N  →  contextUsageEvent  →  meteringEvent  →  clean EOF

stopReason is only parsed from metadataEvent (proxy/kiro.go, added in #145), and classifyStreamIntegrity (proxy/account_failover.go, added in #146) treats "content but no stopReason" as truncation. So on these accounts every successful response is classified as truncated, retried twice, then failed.

This is a regression, bisected: before #145 (2b93ffb) there was no stopReason concept in proxy/kiro.go and no stream_integrity.go / account_failover.go at all, so IdC accounts worked fine on v1.1.5 and earlier.

I believe this line in the #146 description is where the assumption entered:

既有夹具补了 metadataEvent stopReason 帧(真实上游会发,不是放宽断言)

That holds for Builder ID / social accounts, but not for profileArn-authenticated IdC / Enterprise profiles. The Kiro IDE source referenced in #146 is the IDE client's view, which doesn't cover this profile type.


IdC / 企业版账号自 #145 + #146 起 100% 请求失败,一律返回上述错误。

根因:该 backend 从不发 metadataEvent,因此 stopReason 永远缺失。这类 profile 上一次完整成功的回答就是上面那个帧序列。而 stopReason 只从 metadataEvent 解析(#145 引入),classifyStreamIntegrity#146 引入)又把"有内容但无 stopReason"判为截断,于是每个成功响应都被判成截断。

已 bisect 确认为回归:#1452b93ffb)之前没有 stopReason 概念,也没有 stream_integrity.go / account_failover.go,IdC 账号在 v1.1.5 及更早一直正常。

Steps to reproduce / 复现步骤

  1. Add an account with authMethod: IdC / provider: Enterprise (has profileArn, startUrl).
  2. Send any request:
    curl -X POST http://localhost:8080/v1/messages \
      -H "Content-Type: application/json" -H "anthropic-version: 2023-06-01" \
      -d '{"model":"claude-sonnet-4","max_tokens":64,"messages":[{"role":"user","content":"Say exactly: ok"}]}'
  3. Got the upstream truncated response without stop reason error; expected 200 with stop_reason: end_turn.

Fails identically for every model (sonnet-4 / 4.5 / 4.6, haiku-4.5, opus-4.8), both streaming and non-streaming, and all three OAuth endpoints (preferredEndpoint = kiro / codewhisperer / amazonq with endpointFallback: false).

Direct-to-upstream A/B, decoding the raw AWS event stream — same payload, once with agentTaskType: "vibe" + agentContinuationId + x-amzn-kiro-agent-mode: vibe, once without. Both returned byte-identical HTTP 200 streams, and neither contained a metadataEvent frame. Verified against both q.us-east-1.amazonaws.com and codewhisperer.us-east-1.amazonaws.com. Ruled out as causes: cache_control, model choice, max_tokens, endpoint, streaming mode, and the vibe agent-mode markers.

Decoded frames from a successful turn (Say exactly: ok):

:event-type assistantResponseEvent  {"content":"ok","modelId":"claude-sonnet-4"}
:event-type contextUsageEvent       {"contextUsagePercentage":2.0525}
:event-type meteringEvent           {"unit":"credit","unitPlural":"credits","usage":0.0173}
<clean EOF — no metadataEvent>

Note: the built-in account test cannot detect this / 内置账号测试无法发现该问题

apiTestAccount (proxy/handler.go:4123) reports success throughout the outage, for two reasons:

  1. It calls CallKiroAPIContext directly, bypassing runKiroWithIntegrityRetry and therefore classifyStreamIntegrity.
  2. Its callback (proxy/handler.go:4166-4173) has no OnStopReason field at all, so it never looks at the stop reason — receiving any text is enough to report success.

This made the failure quite confusing to diagnose: the account test passes, credentials are valid, but every real request fails. Might be worth having the test exercise the same integrity path.

Relevant log output / 相关日志

WARN [StreamIntegrity] upstream truncated response without stop reason on <account>; retrying same account (1/2)
WARN [StreamIntegrity] upstream truncated response without stop reason on <account>; retrying same account (2/2)
WARN [StreamIntegrity] giving up after retries: upstream truncated response without stop reason

Environment / 环境信息

  • Version: ghcr.io/quorinex/kiro-go:latest (v1.1.5) and source at f8f6071 (fix(stream): 识别空流/截断流,不再伪造成功 #146)
  • Deployment: Docker image on Kubernetes; also reproduced with go build from source
  • Account: authMethod: IdC, provider: Enterprise, region: us-east-1, with profileArn
  • OS: linux/amd64 (container), macOS 15 (source build)

Additional Context / 补充信息

I have a working fix and would be happy to open a PR if you agree with the direction. Wanted to align on approach first, since #146 was a deliberate design decision and I don't want to weaken it.

Proposed approach: accept meteringEvent as an alternative terminal signal at clean EOF, synthesizing end_turn (or tool_use when a tool call was delivered), and only when upstream provided no stopReason of its own — upstream's value always wins.

Why meteringEvent specifically: it closes out billing for the turn, so upstream cannot emit it before the turn is finished. That preserves exactly the guarantee #146 exists for — a stream that dies mid-answer carries no metering frame, so it is still classified as truncated and still retried. contextUsageEvent deliberately does not count, since window occupancy isn't inherently a completion statement.

Diff is ~35 lines in parseEventStreamTracked, plus 4 regression tests (6 subtests) including negative cases: genuine truncation with no trailing frames, and "contextUsage only, no metering" — both must still be classified as truncated. gofmt / go vet / go test ./... -count=1 all green.

Verified end-to-end against a real IdC account: non-streaming, streaming, tool use, and the OpenAI-compatible path all return correct stop_reason / finish_reason.

If you'd prefer a different shape — e.g. gating it per auth method, or having classifyStreamIntegrity take the metering signal as an explicit parameter rather than synthesizing a stop reason in the parser — I'm happy to implement it that way instead.


补丁已写好并通过验证,如方向认可我可以提 PR。先开 issue 对齐思路,因为 #146 是有意的设计决策,不想削弱它的保护。

方案:把 meteringEvent 作为 clean EOF 处的备用终止信号(结账帧,上游不可能在回答结束前发),仅在上游未提供 stopReason 时生效。真截断收不到 metering 帧,仍会被判为截断,#146 的保护不受影响。contextUsageEvent 刻意不计入。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions