OpenRouter/Qwen3ストリーム解析失敗 - reasoning_detailsフィールド未処理 - OpenRouter/Qwen3 Stream Parsing Fails — reasoning_details Field Not Handled
openrouter/qwen/qwen3-235b-a22bまたは同様のQwen3モデルをOpenRouter経由で使用时、すべての応答が'incomplete turn detected: payloads=0'で失敗します。原因是ストリームパーサーがreasoning_detailsフィールドを処理しないため、contentブロックがゼロしか_assembleされません。
🔍 症状
主なエラー現象
OpenRouterを通じてQwen3モデルのいずれかにクエリを実行すると、エージェントは即座に以下で失敗します:
incomplete turn detected: runId=abc123-xyz stopReason=stop payloads=0エンドユーザーは以下を観察します:
⚠️ Agent couldn't generate a response. Please try again.技術的な動作
ストリームパーサーはOpenRouterから有効なdeltaイベントを受け取りますが、どのフィールドもアシスタントコンテンツとして認識されません:
reasoning_content— Qwen3レスポンスには存在しないreasoning— Qwen3レスポンスには存在しないreasoning_text— Qwen3レスポンスには存在しないreasoning_details— 存在するが未処理であり、ペイロードのアセンブルがゼロになる
生のOpenRouterストリームイベント
{
"choices": [{
"delta": {
"reasoning_details": [
{
"type": "reasoning.text",
"text": "Let me work through this problem step by step...",
"format": "unknown",
"index": 0
}
]
},
"finish_reason": "stop",
"index": 0
}],
"model": "qwen3-235b-a22b",
"id": "gen-...",
"object": "chat.completion.chunk"
}診断ログ出力
[gateway] Received 47 stream events for runId=abc123-xyz
[gateway] Assembled 0 payload blocks (reasoning_content=0, content=0)
[gateway] WARNING: payloads=0 triggers incomplete turn path
[gateway] Returning error to client: "incomplete turn detected"影響を受けるモデル
openrouter/qwen/qwen3-235b-a22bopenrouter/qwen/qwen3-235b-a22b-2507openrouter/qwen/qwen3-32breasoning_detailsを出力するOpenRouter経由のQwen3バリアント全般
🧠 原因
アーキテクチャ概要
OpenClawのストリーミングアーキテクチャは2つの主要コンポーネントで構成されています:
- リクエスト側ラッパー:
createOpenRouterWrapper— OpenRouter固有のヘッダー/パラメータでアウトバウンドリクエストをラップ - レスポンス側パーサー:
src/agents/openai-transport-stream.ts— プロバイダーからのSSEストリームイベントを構造化されたdeltaイベントにパース
パース失敗シーケンス
ステップ1 — OpenRouterがQwen3推論を返す
Qwen3がプロンプトを処理すると、推論トークンを非標準フィールドで出力します:
"delta": {
"reasoning_details": [{
"type": "reasoning.text",
"text": "Analyzing the query...",
"format": "unknown",
"index": 0
}]
}ステップ2 — ストリームパーサーがフィールドを認識しない
openai-transport-stream.tsの現在のパーサーは以下のフィールドをチェックします:
// Current field mapping (incomplete)
const REASONING_FIELDS = [
'reasoning_content',
'reasoning',
'reasoning_text'
];
// Reasoning token extraction logic
if (REASONING_FIELDS.some(f => delta[f])) {
emitReasoningToken(delta[f]);
}
// reasoning_details is NOT in this list — completely ignoredステップ3 — ペイロードアセンブルがゼロ
reasoning_detailsが処理されないため:
- 推論トークンがアグリゲータにエミットされない
- アシスタントコンテンツブロックが作成されない
payloads配列が空のままになる
ステップ4 — 不完全なターンの検出
src/agents/pi-embedded-runner/run.ts内:
if (payloads.length === 0) {
logger.warn(`incomplete turn detected: payloads=0`);
throw new IncompleteTurnError();
}OpenRouterレスポンス正規化のギャップ
createOpenRouterWrapperはリクエスト側の変換のみを処理します:
// createOpenRouterWrapper — request wrapper only
function createOpenRouterWrapper(config: ProviderConfig) {
return {
async sendRequest(payload: ChatPayload) {
// Adds OpenRouter-specific headers and extra_body
return transformRequest(payload);
}
// NO response transformation/cleanup
};
}つまり、reasoning_detailsはラッパーを通じて変更されずに通過し、未処理のままパーサーに到達します。
フィールドフォーマットの比較
| フィールド名 | OpenRouter経由のQwen3 | OpenClawパーサーサポート |
|---|---|---|
reasoning_content | なし | あり |
reasoning | なし | あり |
reasoning_text | なし | あり |
reasoning_details | あり | なし |
🛠️ 解決手順
オプションA: ストリームパーサーへのreasoning_detailsサポートの追加(推奨)
ファイル: src/agents/openai-transport-stream.ts
変更前:
function extractReasoningFromDelta(delta: Record<string, any>): string | null {
if (delta.reasoning_content) {
return String(delta.reasoning_content);
}
if (delta.reasoning) {
return String(delta.reasoning);
}
if (delta.reasoning_text) {
return String(delta.reasoning_text);
}
return null;
}変更後:
function extractReasoningFromDelta(delta: Record<string, any>): string | null {
// Existing fields
if (delta.reasoning_content) {
return String(delta.reasoning_content);
}
if (delta.reasoning) {
return String(delta.reasoning);
}
if (delta.reasoning_text) {
return String(delta.reasoning_text);
}
// OpenRouter Qwen3 reasoning_details field
if (delta.reasoning_details && Array.isArray(delta.reasoning_details)) {
const details = delta.reasoning_details;
if (details.length > 0 && details[0].text) {
return String(details[0].text);
}
}
return null;
}追加の変更 — 配列構造の処理:
reasoning_detailsが逐次コンテンツを持つ複数のエントリを含む場合:
// In the delta processing loop
if (delta.reasoning_details && Array.isArray(delta.reasoning_details)) {
for (const detail of delta.reasoning_details) {
if (detail.type === 'reasoning.text' && detail.text) {
emitReasoningToken(String(detail.text));
}
}
}オプションB: OpenRouterラッパーへのレスポンス正規化の追加
ファイル: src/providers/openrouter/adapter.ts(またはラッパーファイル)
function normalizeOpenRouterResponse(delta: Record<string, any>): Record<string, any> {
const normalized = { ...delta };
// Map reasoning_details to reasoning_text for compatibility
if (normalized.reasoning_details && Array.isArray(normalized.reasoning_details)) {
const firstDetail = normalized.reasoning_details[0];
if (firstDetail && firstDetail.text) {
normalized.reasoning_text = firstDetail.text;
}
// Remove the original to prevent duplicate handling
delete normalized.reasoning_details;
}
return normalized;
}次に、ストリーム処理で適用します:
stream.on('data', (chunk) => {
const delta = JSON.parse(chunk);
const normalized = normalizeOpenRouterResponse(delta);
processDelta(normalized);
});オプションC: 不完全なターン検出での不明な推論フィールドの無視
ファイル: src/agents/pi-embedded-runner/run.ts
// Instead of hard failure on payloads=0, check if reasoning was received
if (payloads.length === 0) {
if (reasoningTokens.length > 0) {
logger.info(`Turn completed with reasoning-only output (${reasoningTokens.length} tokens)`);
return { payloads: [], reasoning: reasoningTokens };
}
logger.warn(`incomplete turn detected: payloads=0`);
throw new IncompleteTurnError();
}注意: オプションCはフォールバックメカニズムであり、完全な解決にはオプションAと組み合わせる必要があります。
🧪 検証
テスト1: 直接ストリームキャプチャ
# Terminal 1: Start a local capture
nc -l 9999 > qwen3-stream.json
# Terminal 2: Run your agent with verbose logging
OPENCLAW_LOG_LEVEL=debug npm run agent:run -- --model "openrouter/qwen/qwen3-235b-a22b" --prompt "Hello"
# Check captured stream
cat qwen3-stream.json | grep -o '"reasoning_details"' | wc -l
# Expected: number of reasoning_details occurrences
# Verify reasoning_details contains text
cat qwen3-stream.json | jq '.choices[].delta.reasoning_details[].text' | head -5テスト2: パーサー関数のユニットテスト
// test/agents/openai-transport-stream.test.ts
describe('extractReasoningFromDelta', () => {
it('should extract reasoning from reasoning_details (OpenRouter Qwen3)', () => {
const delta = {
reasoning_details: [{
type: 'reasoning.text',
text: 'Step 1: Analysis complete',
format: 'unknown',
index: 0
}]
};
const result = extractReasoningFromDelta(delta);
expect(result).toBe('Step 1: Analysis complete');
});
it('should handle multiple reasoning_details entries', () => {
const delta = {
reasoning_details: [
{ type: 'reasoning.text', text: 'First part', index: 0 },
{ type: 'reasoning.text', text: 'Second part', index: 1 }
]
};
const tokens = [];
// Simulate extraction loop
if (delta.reasoning_details) {
for (const detail of delta.reasoning_details) {
if (detail.text) tokens.push(detail.text);
}
}
expect(tokens).toEqual(['First part', 'Second part']);
});
});テスト3: モックOpenRouterとの統合テスト
// test/integration/qwen3-stream.test.ts
it('should successfully parse Qwen3 stream from OpenRouter', async () => {
const mockStream = new PassThrough();
// Simulate OpenRouter Qwen3 stream
const events = [
{ choices: [{ delta: { reasoning_details: [{ type: 'reasoning.text', text: 'Thinking...', index: 0 }] } }] },
{ choices: [{ delta: { content: 'Final response' } }] },
{ choices: [{ delta: {}, finish_reason: 'stop' }] }
];
events.forEach(e => mockStream.write(`data: ${JSON.stringify(e)}\n\n`));
mockStream.end();
const parser = new OpenAIStreamParser();
const result = await parser.parse(mockStream);
expect(result.payloads.length).toBeGreaterThan(0);
expect(result.reasoningTokens.length).toBeGreaterThan(0);
});テスト4: エンドツーエンド検証
# Start the gateway with debug logging
LOG_LEVEL=debug node gateway.js
# Send test request
curl -X POST http://localhost:3000/v1/agents/test-agent/messages \
-H "Content-Type: application/json" \
-d '{"model": "openrouter/qwen/qwen3-235b-a22b", "messages": [{"role": "user", "content": "Test"}]}'
# Verify log output contains:
# - "reasoning_details detected and processed"
# - "payloads=N" where N > 0
# - NO "incomplete turn detected"修正後の期待されるコンソール出力
[gateway] Processing stream for runId=test-123
[gateway] ✓ Recognized reasoning_details field (Qwen3/OpenRouter)
[gateway] Emitted 127 reasoning tokens
[gateway] Assembled 3 content blocks
[gateway] Turn completed successfully: payloads=3
[agent] Response generated successfully⚠️ よくある落とし穴
1. 効果のない誤った回避策の特定
thinkingDefault: "off"— OpenClawのthinking effort注入のみを無効化;OpenRouterへのexclude: true送信は行わないproviderOptions.openrouter.extra_body.enable_thinking: false— Qwen3ではOpenRouter 의해 적용되지 않음providerOptions.openrouter.extra_body.thinking: { type: "disabled" }— このエンドポイントでは無効なパラメータtools.allow: []— ストリームフィールド処理には影響しない
2. モデルバリアントに関する前提
誤った前提: 異なるQwen3バリアント(qwen3-235b-a22b、qwen3-32bなど)は異なるフィールド名を使用する。
実際のところ: OpenRouter経由のすべてのQwen3バリアントは同じreasoning_detailsスキーマを使用する。
3. OpenRouterと直接APIの混同
curlでOpenRouter APIに直接テストする場合:
# This works (no parsing issue)
curl https://openrouter.ai/api/v1/chat/completions \
-H "Authorization: Bearer $OPENROUTER_KEY" \
-d '{"model": "qwen/qwen3-235b-a22b", "messages": [...]}'
# Returns content + reasoning_details correctlyこれはユーザーを誤解させ、モデルが動作していると考えてしまいます — 問題はOpenClawのストリームパースレイヤーに固有の問題です。
4. Docker環境のキャッシュ問題
# After patching the parser, ensure fresh build
docker build --no-cache -t openclaw:latest .
# Verify the patched file is included
docker run openclaw:latest grep -l "reasoning_details" /app/dist/openai-transport-stream.js5. TypeScriptコンパイルの不一致
ソースから実行する場合:
# Ensure TypeScript is recompiled after patch
npm run build
# Verify the output contains reasoning_details handling
grep "reasoning_details" dist/agents/openai-transport-stream.js6. ストリーム処理における競合状態
reasoning_detailsがfinish_reasonイベント後に到着する場合:
// Problematic: Buffer reasoning_details until stream completion
const pendingReasoning: string[] = [];
// Safe: Process immediately, but buffer finish check
stream.on('data', (chunk) => {
const delta = parse(chunk);
if (delta.reasoning_details) {
pendingReasoning.push(...extractTexts(delta.reasoning_details));
}
if (delta.finish_reason) {
flushPendingReasoning(pendingReasoning);
}
});7. 複数インデックスを持つ推論の詳細
Qwen3は非連続のインデックスで推論を出力場合があります:
"reasoning_details": [
{ "type": "reasoning.text", "text": "...", "index": 2 },
{ "type": "reasoning.text", "text": "...", "index": 0 },
{ "type": "reasoning.text", "text": "...", "index": 1 }
]修正では、以下のいずれかで順序外のエントリを処理する必要があります:
- エミット前にインデックスでバッファリングしてソート
- すぐにインデックスメタデータ付きでエミットして下流での順序付けを可能にする
🔗 関連するエラー
| エラーコード / メッセージ | 説明 | 関連性 |
|---|---|---|
incomplete turn detected: payloads=0 | 主要な症状 — コンテンツブロックゼロでターンが完了 | このバグの直接的な現れ |
incomplete turn detected: runId=… | pi-embedded-runner/run.tsでログ出力される不完全ターンの一般的エラー | ゼロペイロードの下流の結果 |
ERROR: reasoning model returned empty content | 一部のゲートウェイバージョンでの代替エラー表現 | 同じ根本原因 |
SSE parse error: Unexpected token at offset | 不正なストリームイベント | 無関係なストリーム解析問題 |
Provider timeout: OpenRouter | OpenRouterエンドポイントタイムアウト | 別のカテゴリ(ネットワーク) |
Model not found: qwen3-xxx | 無効なモデル識別子 | 設定エラー、無関係 |
Unauthorized | 無効なOpenRouter APIキー | 認証問題 |
関連する過去の問題
- Issue #1042: "DeepSeek stream parsing fails — reasoning_content not handled" — 異なるプロバイダー/モデルの組み合わせでの同じパターン
- Issue #892: "Claude streaming breaks on extended thinking blocks" — Anthropicレスポンスでの同様のフィールド未処理パターン
- PR #1156: "Add reasoning_text field support to OpenAI transport" — 認識されたフィールドの1つを追加したコミュニティ貢献
- PR #1234: "OpenRouter compatibility layer" —
createOpenRouterWrapperを導入したがレスポンス正規化を失念
既知の影響を受ける設定
| プロバイダー | モデル | OpenClawバージョン | ステータス |
|---|---|---|---|
| OpenRouter | qwen/qwen3-235b-a22b | v2026.4.14 | 影響あり |
| OpenRouter | qwen/qwen3-32b | v2026.4.14 | 影響あり |
| OpenRouter | qwen/qwen3-235b-a22b-2507 | v2026.4.14 | 影響あり |
| Direct API | qwen3-235b-a22b | v2026.4.14 | 影響なし(レスポンスフォーマットが異なる) |
| LiteLLM Proxy | qwen/qwen3-235b-a22b | v2026.4.14 | 回避策あり(プロキシが不明なフィールドをストリップ) |