From 9f3040cf4922db763a236b0f9f7c5589cbfaf785 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 11 Feb 2026 12:26:03 +0300 Subject: [PATCH] feat(kiro): add request-side extended thinking support and structured reasoning outputs --- README.md | 71 ++++- src/converters/strategies/ClaudeConverter.js | 24 +- src/converters/strategies/OpenAIConverter.js | 34 ++- .../strategies/OpenAIResponsesConverter.js | 7 +- src/providers/claude/claude-kiro.js | 288 +++++++++++++----- tests/claude-converter-thinking.test.js | 43 +++ tests/kiro-thinking-parsing.test.js | 40 +++ tests/openai-converter-thinking.test.js | 55 ++++ ...penai-responses-converter-thinking.test.js | 27 ++ 9 files changed, 490 insertions(+), 99 deletions(-) create mode 100644 tests/claude-converter-thinking.test.js create mode 100644 tests/kiro-thinking-parsing.test.js create mode 100644 tests/openai-converter-thinking.test.js create mode 100644 tests/openai-responses-converter-thinking.test.js diff --git a/README.md b/README.md index a9e4711..fa5c805 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,46 @@ In the Web UI management interface, you can complete authorization configuration 3. **Best Practice**: Recommended to use with **Claude Code** for optimal experience 4. **Important Notice**: Kiro service usage policy has been updated, please visit the official website for the latest usage restrictions and terms +#### Kiro Extended Thinking (Claude Models) +AIClient-2-API supports Kiro extended thinking when using Claude-compatible requests (`/v1/messages`) or OpenAI-compatible requests (`/v1/chat/completions`) routed to `claude-kiro-oauth`. + +**Claude-compatible (`/v1/messages`)**: +```bash +curl http://localhost:3000/claude-kiro-oauth/v1/messages \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer your-api-key" \ + -d '{ + "model": "claude-sonnet-4-5", + "max_tokens": 1024, + "thinking": { "type": "enabled", "budget_tokens": 10000 }, + "messages": [{ "role": "user", "content": "Solve this step by step." }] + }' +``` + +**OpenAI-compatible (`/v1/chat/completions`)**: +```bash +curl http://localhost:3000/claude-kiro-oauth/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer your-api-key" \ + -d '{ + "model": "claude-sonnet-4-5", + "messages": [{ "role": "user", "content": "Solve this step by step." }], + "extra_body": { + "anthropic": { + "thinking": { "type": "enabled", "budget_tokens": 10000 } + } + } + }' +``` + +**Adaptive mode**: +- Claude: `"thinking": { "type": "adaptive", "effort": "high" }` +- OpenAI: `"extra_body.anthropic.thinking": { "type": "adaptive", "effort": "high" }` + +Notes: +- `budget_tokens` is clamped to `[1024, 24576]` (default `20000` if omitted/invalid). +- Token acquisition/refresh/pool rotation is unchanged. + #### iFlow OAuth Configuration 1. **First Authorization**: In Web UI's "Configuration" or "Provider Pools" page, click the "Generate Authorization" button for iFlow 2. **Phone Login**: The system will open the iFlow authorization page, complete login verification using your phone number @@ -414,7 +454,36 @@ Support excluding unsupported models through `notSupportedModels` configuration, - Some accounts cannot access specific models due to quota or permission restrictions - Need to assign different model access permissions to different accounts -#### 3. Cross-Type Fallback Configuration +#### 3. Provider Priority Configuration + +Support deterministic account ordering through a per-node `priority` field in `provider_pools.json`. + +**Configuration** (smaller number = higher priority): + +```json +{ + "claude-kiro-oauth": [ + { + "uuid": "primary-node-uuid", + "priority": 1, + "checkHealth": true + }, + { + "uuid": "backup-node-uuid", + "priority": 2, + "checkHealth": true + } + ] +} +``` + +**How It Works**: +- The pool manager first filters healthy/available nodes by the lowest `priority` value +- Only nodes in that highest-priority tier participate in LRU/score-based balancing +- If the whole highest-priority tier becomes unavailable, the next priority tier is used automatically +- If `priority` is omitted or invalid, default `100` is applied (backward compatible behavior) + +#### 4. Cross-Type Fallback Configuration When all accounts under a Provider Type (e.g., `gemini-cli-oauth`) are exhausted due to 429 quota limits or marked as unhealthy, the system can automatically fallback to another compatible Provider Type (e.g., `gemini-antigravity`) instead of returning an error directly. diff --git a/src/converters/strategies/ClaudeConverter.js b/src/converters/strategies/ClaudeConverter.js index ecf72cf..d8246bf 100644 --- a/src/converters/strategies/ClaudeConverter.js +++ b/src/converters/strategies/ClaudeConverter.js @@ -140,12 +140,7 @@ export class ClaudeConverter extends BaseConverter { for (const item of msg.content) { if (item && typeof item === 'object' && item.type === "tool_result") { const toolUseId = item.tool_use_id || item.id || ""; - let contentStr = item.content || ""; - if (typeof contentStr === 'object') { - contentStr = JSON.stringify(contentStr); - } else { - contentStr = String(contentStr); - } + const contentStr = String(item.content || ""); tempOpenAIMessages.push({ role: "tool", tool_call_id: toolUseId, @@ -310,6 +305,17 @@ export class ClaudeConverter extends BaseConverter { }; } + // Extract thinking blocks into OpenAI-style `reasoning_content`. + let reasoningContent = ''; + if (Array.isArray(claudeResponse.content)) { + for (const block of claudeResponse.content) { + if (!block || typeof block !== 'object') continue; + if (block.type === 'thinking') { + reasoningContent += (block.thinking ?? block.text ?? ''); + } + } + } + // 检查是否包含 tool_use const hasToolUse = claudeResponse.content.some(block => block && block.type === 'tool_use'); @@ -349,6 +355,10 @@ export class ClaudeConverter extends BaseConverter { message.content = this.processClaudeResponseContent(claudeResponse.content); } + if (reasoningContent) { + message.reasoning_content = reasoningContent; + } + // 处理 finish_reason let finishReason = 'stop'; if (claudeResponse.stop_reason === 'end_turn') { @@ -2186,4 +2196,4 @@ export class ClaudeConverter extends BaseConverter { } } -export default ClaudeConverter; \ No newline at end of file +export default ClaudeConverter; diff --git a/src/converters/strategies/OpenAIConverter.js b/src/converters/strategies/OpenAIConverter.js index 9133ed1..f8bca98 100644 --- a/src/converters/strategies/OpenAIConverter.js +++ b/src/converters/strategies/OpenAIConverter.js @@ -149,14 +149,10 @@ export class OpenAIConverter extends BaseConverter { if (message.role === 'tool') { // 工具结果消息 - let toolContent = message.content; - if (typeof toolContent === 'object' && toolContent !== null) { - toolContent = JSON.stringify(toolContent); - } content.push({ type: 'tool_result', tool_use_id: message.tool_call_id, - content: toolContent + content: safeParseJSON(message.content) }); claudeMessages.push({ role: 'user', content: content }); } else if (message.role === 'assistant' && (message.tool_calls?.length || message.function_calls?.length)) { @@ -278,6 +274,32 @@ export class OpenAIConverter extends BaseConverter { claudeRequest.tool_choice = this.buildClaudeToolChoice(openaiRequest.tool_choice); } + // Optional passthrough: request-side "thinking" controls for Claude/Kiro. + // OpenAI-compatible clients can provide these via `extra_body.anthropic.thinking`. + // We intentionally keep normalization minimal here; provider implementations + // (e.g. Kiro) clamp budgets and apply defaults. + const extThinking = openaiRequest?.extra_body?.anthropic?.thinking; + if (extThinking && typeof extThinking === 'object' && !Array.isArray(extThinking)) { + const type = String(extThinking.type || '').toLowerCase().trim(); + if (type === 'enabled') { + const thinkingCfg = { type: 'enabled' }; + if (extThinking.budget_tokens !== undefined) { + const n = parseInt(extThinking.budget_tokens, 10); + if (Number.isFinite(n)) { + thinkingCfg.budget_tokens = n; + } + } + claudeRequest.thinking = thinkingCfg; + } else if (type === 'adaptive') { + const effortRaw = typeof extThinking.effort === 'string' ? extThinking.effort : ''; + const effort = effortRaw.toLowerCase().trim(); + const normalizedEffort = (effort === 'low' || effort === 'medium' || effort === 'high') ? effort : 'high'; + claudeRequest.thinking = { type: 'adaptive', effort: normalizedEffort }; + } else if (type === 'disabled') { + // Explicitly disabled: omit thinking config. + } + } + return claudeRequest; } @@ -1619,4 +1641,4 @@ export class OpenAIConverter extends BaseConverter { } -export default OpenAIConverter; \ No newline at end of file +export default OpenAIConverter; diff --git a/src/converters/strategies/OpenAIResponsesConverter.js b/src/converters/strategies/OpenAIResponsesConverter.js index 858b9f2..a8082f6 100644 --- a/src/converters/strategies/OpenAIResponsesConverter.js +++ b/src/converters/strategies/OpenAIResponsesConverter.js @@ -380,9 +380,14 @@ export class OpenAIResponsesConverter extends BaseConverter { // 处理 reasoning effort if (responsesRequest.reasoning?.effort) { + const effort = String(responsesRequest.reasoning.effort || '').toLowerCase().trim(); + let budgetTokens = 20000; + if (effort === 'low') budgetTokens = 2048; + else if (effort === 'medium') budgetTokens = 8192; + else if (effort === 'high') budgetTokens = 20000; claudeRequest.thinking = { type: 'enabled', - budget_tokens: 1024 // 默认 budget + budget_tokens: budgetTokens }; } diff --git a/src/providers/claude/claude-kiro.js b/src/providers/claude/claude-kiro.js index 883d36e..072e556 100644 --- a/src/providers/claude/claude-kiro.js +++ b/src/providers/claude/claude-kiro.js @@ -14,12 +14,14 @@ import { isRetryableNetworkError, MODEL_PROVIDER, formatExpiryLog } from '../../ import { getProviderPoolManager } from '../../services/service-manager.js'; const KIRO_THINKING = { + MIN_BUDGET_TOKENS: 1024, MAX_BUDGET_TOKENS: 24576, DEFAULT_BUDGET_TOKENS: 20000, START_TAG: '', END_TAG: '', MODE_TAG: '', MAX_LEN_TAG: '', + EFFORT_TAG: '', }; const KIRO_CONSTANTS = { @@ -120,6 +122,42 @@ function findRealTag(text, tag, startIndex = 0) { } } +function isWhitespaceOnly(text) { + if (text === null || text === undefined) return true; + return String(text).trim().length === 0; +} + +/** + * Find a "real" thinking end tag that is not quoted/backticked and is followed by '\n\n'. + * This avoids prematurely closing a thinking block when the model mentions `` + * inside the thinking content. + */ +function findRealThinkingEndTag(buffer, startIndex = 0) { + let searchStart = Math.max(0, startIndex); + while (true) { + const pos = findRealTag(buffer, KIRO_THINKING.END_TAG, searchStart); + if (pos === -1) return -1; + const after = buffer.slice(pos + KIRO_THINKING.END_TAG.length); + if (after.startsWith('\n\n')) return pos; + searchStart = pos + 1; + } +} + +/** + * Find a "real" thinking end tag only when it is at the buffer end (after it is whitespace only). + * This is used for boundary-event scenarios (tool_use starts immediately after thinking, or stream end). + */ +function findRealThinkingEndTagAtBufferEnd(buffer, startIndex = 0) { + let searchStart = Math.max(0, startIndex); + while (true) { + const pos = findRealTag(buffer, KIRO_THINKING.END_TAG, searchStart); + if (pos === -1) return -1; + const after = buffer.slice(pos + KIRO_THINKING.END_TAG.length); + if (isWhitespaceOnly(after)) return pos; + searchStart = pos + 1; + } +} + /** * 通用的括号匹配函数 - 支持多种括号类型 * @param {string} text - 要搜索的文本 @@ -742,18 +780,32 @@ async saveCredentialsToFile(filePath, newData) { value = KIRO_THINKING.DEFAULT_BUDGET_TOKENS; } value = Math.floor(value); + if (value < KIRO_THINKING.MIN_BUDGET_TOKENS) value = KIRO_THINKING.MIN_BUDGET_TOKENS; return Math.min(value, KIRO_THINKING.MAX_BUDGET_TOKENS); } _generateThinkingPrefix(thinking) { - if (!thinking || thinking.type !== 'enabled') return null; - const budget = this._normalizeThinkingBudgetTokens(thinking.budget_tokens); - return `enabled${budget}`; + if (!thinking || typeof thinking !== 'object') return null; + const type = String(thinking.type || '').toLowerCase().trim(); + + if (type === 'enabled') { + const budget = this._normalizeThinkingBudgetTokens(thinking.budget_tokens); + return `enabled${budget}`; + } + + if (type === 'adaptive') { + const effortRaw = typeof thinking.effort === 'string' ? thinking.effort : ''; + const effort = effortRaw.toLowerCase().trim(); + const normalizedEffort = (effort === 'low' || effort === 'medium' || effort === 'high') ? effort : 'high'; + return `adaptive${normalizedEffort}`; + } + + return null; } _hasThinkingPrefix(text) { if (!text) return false; - return text.includes(KIRO_THINKING.MODE_TAG) || text.includes(KIRO_THINKING.MAX_LEN_TAG); + return text.includes(KIRO_THINKING.MODE_TAG) || text.includes(KIRO_THINKING.MAX_LEN_TAG) || text.includes(KIRO_THINKING.EFFORT_TAG); } _toClaudeContentBlocksFromKiroText(content) { @@ -767,8 +819,14 @@ async saveCredentialsToFile(filePath, newData) { const before = raw.slice(0, startPos); let rest = raw.slice(startPos + KIRO_THINKING.START_TAG.length); - - const endPosInRest = findRealTag(rest, KIRO_THINKING.END_TAG); + + // Strip a single leading newline after `` for cleaner blocks. + if (rest.startsWith('\r\n')) rest = rest.slice(2); + else if (rest.startsWith('\n')) rest = rest.slice(1); + + let endPosInRest = findRealThinkingEndTag(rest); + if (endPosInRest === -1) endPosInRest = findRealThinkingEndTagAtBufferEnd(rest); + let thinking = ''; let after = ''; if (endPosInRest === -1) { @@ -779,11 +837,12 @@ async saveCredentialsToFile(filePath, newData) { } if (after.startsWith('\n\n')) after = after.slice(2); + if (isWhitespaceOnly(after)) after = ''; const blocks = []; - if (before) blocks.push({ type: "text", text: before }); + if (before && !isWhitespaceOnly(before)) blocks.push({ type: "text", text: before }); blocks.push({ type: "thinking", thinking }); - if (after) blocks.push({ type: "text", text: after }); + if (after && !isWhitespaceOnly(after)) blocks.push({ type: "text", text: after }); return blocks; } @@ -887,62 +946,35 @@ async saveCredentialsToFile(filePath, newData) { }; toolsContext = { tools: [placeholderTool] }; } else { - const MAX_DESCRIPTION_LENGTH = 9216; + const MAX_DESCRIPTION_LENGTH = 9216; - let truncatedCount = 0; - const kiroTools = filteredTools - .filter(tool => { - // 过滤掉描述为空的工具 - if (!tool.description || tool.description.trim() === '') { - logger.info(`[Kiro] Ignoring tool with empty description: ${tool.name}`); - return false; - } - return true; - }) - .map(tool => { - let desc = tool.description || ""; - const originalLength = desc.length; - - if (desc.length > MAX_DESCRIPTION_LENGTH) { - desc = desc.substring(0, MAX_DESCRIPTION_LENGTH) + "..."; - truncatedCount++; - logger.info(`[Kiro] Truncated tool '${tool.name}' description: ${originalLength} -> ${desc.length} chars`); - } - - return { - toolSpecification: { - name: tool.name, - description: desc, - inputSchema: { - json: tool.input_schema || {} - } - } - }; - }); + let truncatedCount = 0; + const kiroTools = filteredTools.map(tool => { + let desc = tool.description || ""; + const originalLength = desc.length; - if (truncatedCount > 0) { - logger.info(`[Kiro] Truncated ${truncatedCount} tool description(s) to max ${MAX_DESCRIPTION_LENGTH} chars`); + if (desc.length > MAX_DESCRIPTION_LENGTH) { + desc = desc.substring(0, MAX_DESCRIPTION_LENGTH) + "..."; + truncatedCount++; + logger.info(`[Kiro] Truncated tool '${tool.name}' description: ${originalLength} -> ${desc.length} chars`); } - - // 检查过滤后是否还有有效工具 - if (kiroTools.length === 0) { - logger.info('[Kiro] All tools were filtered out (empty descriptions), adding placeholder tool'); - const placeholderTool = { - toolSpecification: { - name: "no_tool_available", - description: "This is a placeholder tool when no other tools are available. It does nothing.", - inputSchema: { - json: { - type: "object", - properties: {} - } - } + + return { + toolSpecification: { + name: tool.name, + description: desc, + inputSchema: { + json: tool.input_schema || {} } - }; - toolsContext = { tools: [placeholderTool] }; - } else { - toolsContext = { tools: kiroTools }; - } + } + }; + }); + + if (truncatedCount > 0) { + logger.info(`[Kiro] Truncated ${truncatedCount} tool description(s) to max ${MAX_DESCRIPTION_LENGTH} chars`); + } + + toolsContext = { tools: kiroTools }; } } else { // tools 为空或长度为 0 时,自动添加一个占位工具 @@ -1716,7 +1748,13 @@ async saveCredentialsToFile(filePath, newData) { try { const { responseText, toolCalls } = this._processApiResponse(response); - return this.buildClaudeResponse(responseText, false, 'assistant', model, toolCalls, inputTokens); + const thinkingType = requestBody?.thinking?.type; + const thinkingRequested = typeof thinkingType === 'string' && + (thinkingType.toLowerCase() === 'enabled' || thinkingType.toLowerCase() === 'adaptive'); + const contentForClaude = thinkingRequested + ? this._toClaudeContentBlocksFromKiroText(responseText) + : responseText; + return this.buildClaudeResponse(contentForClaude, false, 'assistant', model, toolCalls, inputTokens); } catch (error) { logger.error('[Kiro] Error in generateContent:', error); throw new Error(`Error processing response: ${error.message}`); @@ -2076,17 +2114,22 @@ async saveCredentialsToFile(filePath, newData) { let contextUsagePercentage = null; const messageId = `${uuidv4()}`; - const thinkingRequested = requestBody?.thinking?.type === 'enabled'; + const thinkingType = requestBody?.thinking?.type; + const thinkingRequested = typeof thinkingType === 'string' && + (thinkingType.toLowerCase() === 'enabled' || thinkingType.toLowerCase() === 'adaptive'); const streamState = { thinkingRequested, buffer: '', + pendingTextBeforeThinking: '', inThinking: false, thinkingExtracted: false, thinkingBlockIndex: null, textBlockIndex: null, nextBlockIndex: 0, stoppedBlocks: new Set(), + stripThinkingLeadingNewline: false, + stripTextLeadingNewlinesAfterThinking: false, }; const ensureBlockStart = (blockType) => { @@ -2196,24 +2239,58 @@ async saveCredentialsToFile(filePath, newData) { const startPos = findRealTag(streamState.buffer, KIRO_THINKING.START_TAG); if (startPos !== -1) { const before = streamState.buffer.slice(0, startPos); - if (before) events.push(...createTextDeltaEvents(before)); + const beforeCombined = `${streamState.pendingTextBeforeThinking}${before}`; + // Avoid creating meaningless text blocks before thinking. + if (beforeCombined && !isWhitespaceOnly(beforeCombined)) { + events.push(...createTextDeltaEvents(beforeCombined)); + } + streamState.pendingTextBeforeThinking = ''; streamState.buffer = streamState.buffer.slice(startPos + KIRO_THINKING.START_TAG.length); streamState.inThinking = true; + streamState.stripThinkingLeadingNewline = true; continue; } const safeLen = Math.max(0, streamState.buffer.length - KIRO_THINKING.START_TAG.length); if (safeLen > 0) { const safeText = streamState.buffer.slice(0, safeLen); - if (safeText) events.push(...createTextDeltaEvents(safeText)); + if (safeText) { + if (isWhitespaceOnly(safeText)) { + // Buffer whitespace until we know whether a thinking block appears. + // This prevents a leading text block from being created before thinking. + const maxKeep = 1024; + const remaining = maxKeep - streamState.pendingTextBeforeThinking.length; + if (remaining > 0) { + streamState.pendingTextBeforeThinking += safeText.slice(0, remaining); + } + } else { + const combined = `${streamState.pendingTextBeforeThinking}${safeText}`; + streamState.pendingTextBeforeThinking = ''; + events.push(...createTextDeltaEvents(combined)); + } + } streamState.buffer = streamState.buffer.slice(safeLen); } break; } if (streamState.inThinking) { - const endPos = findRealTag(streamState.buffer, KIRO_THINKING.END_TAG); + // Strip a single leading newline after `` (may be split across chunks). + if (streamState.stripThinkingLeadingNewline) { + if (streamState.buffer.startsWith('\r\n')) { + streamState.buffer = streamState.buffer.slice(2); + streamState.stripThinkingLeadingNewline = false; + } else if (streamState.buffer.startsWith('\n')) { + streamState.buffer = streamState.buffer.slice(1); + streamState.stripThinkingLeadingNewline = false; + } else if (streamState.buffer.length > 0) { + streamState.stripThinkingLeadingNewline = false; + } + } + + let endPos = findRealThinkingEndTag(streamState.buffer); + if (endPos === -1) endPos = findRealThinkingEndTagAtBufferEnd(streamState.buffer); if (endPos !== -1) { const thinkingPart = streamState.buffer.slice(0, endPos); if (thinkingPart) events.push(...createThinkingDeltaEvents(thinkingPart)); @@ -2221,13 +2298,13 @@ async saveCredentialsToFile(filePath, newData) { streamState.buffer = streamState.buffer.slice(endPos + KIRO_THINKING.END_TAG.length); streamState.inThinking = false; streamState.thinkingExtracted = true; + streamState.stripThinkingLeadingNewline = false; events.push(...createThinkingDeltaEvents("")); events.push(...stopBlock(streamState.thinkingBlockIndex)); - if (streamState.buffer.startsWith('\n\n')) { - streamState.buffer = streamState.buffer.slice(2); - } + // Strip '\n\n' after the end tag once we switch back to text (may arrive in next chunk). + streamState.stripTextLeadingNewlinesAfterThinking = true; continue; } @@ -2241,8 +2318,13 @@ async saveCredentialsToFile(filePath, newData) { } if (streamState.thinkingExtracted) { - const rest = streamState.buffer; + let rest = streamState.buffer; streamState.buffer = ''; + if (streamState.stripTextLeadingNewlinesAfterThinking) { + if (rest.startsWith('\r\n\r\n')) rest = rest.slice(4); + else if (rest.startsWith('\n\n')) rest = rest.slice(2); + streamState.stripTextLeadingNewlinesAfterThinking = false; + } if (rest) events.push(...createTextDeltaEvents(rest)); break; } @@ -2323,18 +2405,33 @@ async saveCredentialsToFile(filePath, newData) { currentToolCall = null; } - if (thinkingRequested && streamState.buffer) { + if (thinkingRequested && (streamState.inThinking || streamState.buffer || streamState.pendingTextBeforeThinking)) { if (streamState.inThinking) { logger.warn('[Kiro] Incomplete thinking tag at stream end'); + // Strip a single leading newline after `` if we haven't yet. + if (streamState.stripThinkingLeadingNewline) { + if (streamState.buffer.startsWith('\r\n')) streamState.buffer = streamState.buffer.slice(2); + else if (streamState.buffer.startsWith('\n')) streamState.buffer = streamState.buffer.slice(1); + streamState.stripThinkingLeadingNewline = false; + } yield* pushEvents(createThinkingDeltaEvents(streamState.buffer)); streamState.buffer = ''; yield* pushEvents(createThinkingDeltaEvents("")); yield* pushEvents(stopBlock(streamState.thinkingBlockIndex)); } else if (!streamState.thinkingExtracted) { - yield* pushEvents(createTextDeltaEvents(streamState.buffer)); + const remaining = `${streamState.pendingTextBeforeThinking}${streamState.buffer}`; + streamState.pendingTextBeforeThinking = ''; + if (remaining) yield* pushEvents(createTextDeltaEvents(remaining)); streamState.buffer = ''; } else { - yield* pushEvents(createTextDeltaEvents(streamState.buffer)); + let remaining = streamState.buffer; + streamState.buffer = ''; + if (streamState.stripTextLeadingNewlinesAfterThinking) { + if (remaining.startsWith('\r\n\r\n')) remaining = remaining.slice(4); + else if (remaining.startsWith('\n\n')) remaining = remaining.slice(2); + streamState.stripTextLeadingNewlinesAfterThinking = false; + } + if (remaining) yield* pushEvents(createTextDeltaEvents(remaining)); streamState.buffer = ''; } } @@ -2457,9 +2554,17 @@ async saveCredentialsToFile(filePath, newData) { } // Count thinking prefix tokens if thinking is enabled - if (requestBody.thinking?.type === 'enabled') { - const budget = this._normalizeThinkingBudgetTokens(requestBody.thinking.budget_tokens); - allText += `enabled${budget}`; + if (requestBody.thinking?.type && typeof requestBody.thinking.type === 'string') { + const t = requestBody.thinking.type.toLowerCase().trim(); + if (t === 'enabled') { + const budget = this._normalizeThinkingBudgetTokens(requestBody.thinking.budget_tokens); + allText += `enabled${budget}`; + } else if (t === 'adaptive') { + const effortRaw = typeof requestBody.thinking.effort === 'string' ? requestBody.thinking.effort : ''; + const effort = effortRaw.toLowerCase().trim(); + const normalizedEffort = (effort === 'low' || effort === 'medium' || effort === 'high') ? effort : 'high'; + allText += `adaptive${normalizedEffort}`; + } } // Count all messages tokens @@ -2609,9 +2714,31 @@ async saveCredentialsToFile(filePath, newData) { } else { // Non-streaming response (full message object) const contentArray = []; - let stopReason = "end_turn"; let outputTokens = 0; + // 1) Content blocks (text/thinking) first. + if (Array.isArray(content)) { + for (const block of content) { + if (!block || typeof block !== 'object') continue; + if (block.type === 'text' && typeof block.text === 'string') { + contentArray.push({ type: 'text', text: block.text }); + outputTokens += this.countTextTokens(block.text); + } else if (block.type === 'thinking' && typeof block.thinking === 'string') { + contentArray.push({ type: 'thinking', thinking: block.thinking }); + outputTokens += this.countTextTokens(block.thinking); + } else if (typeof block.text === 'string' && block.text) { + // Best-effort fallback for unknown blocks carrying plain text. + contentArray.push({ type: 'text', text: block.text }); + outputTokens += this.countTextTokens(block.text); + } + } + } else if (content) { + contentArray.push({ type: "text", text: content }); + outputTokens += this.countTextTokens(content); + } + + // 2) Append tool_use blocks (if any). + let stopReason = "end_turn"; if (toolCalls && toolCalls.length > 0) { for (const tc of toolCalls) { let inputObject; @@ -2633,13 +2760,7 @@ async saveCredentialsToFile(filePath, newData) { }); outputTokens += this.countTextTokens(tc.function.arguments); } - stopReason = "tool_use"; // Set stop_reason to "tool_use" when toolCalls exist - } else if (content) { - contentArray.push({ - type: "text", - text: content - }); - outputTokens += this.countTextTokens(content); + stopReason = "tool_use"; } return { @@ -2876,4 +2997,3 @@ async saveCredentialsToFile(filePath, newData) { } } } - diff --git a/tests/claude-converter-thinking.test.js b/tests/claude-converter-thinking.test.js new file mode 100644 index 0000000..2e654e4 --- /dev/null +++ b/tests/claude-converter-thinking.test.js @@ -0,0 +1,43 @@ +import { ClaudeConverter } from '../src/converters/strategies/ClaudeConverter.js'; + +describe('ClaudeConverter thinking -> OpenAI reasoning_content', () => { + let converter; + + beforeEach(() => { + converter = new ClaudeConverter(); + }); + + test('toOpenAIResponse surfaces thinking blocks as reasoning_content', () => { + const claudeResponse = { + content: [ + { type: 'thinking', thinking: 'x' }, + { type: 'text', text: 'y' } + ], + stop_reason: 'end_turn', + usage: { input_tokens: 1, output_tokens: 2 } + }; + + const openai = converter.toOpenAIResponse(claudeResponse, 'claude-sonnet-4-5'); + expect(openai.choices[0].message.content).toBe('y'); + expect(openai.choices[0].message.reasoning_content).toBe('x'); + }); + + test('toOpenAIResponse includes tool_calls and reasoning_content together', () => { + const claudeResponse = { + content: [ + { type: 'thinking', thinking: 'r' }, + { type: 'text', text: 't' }, + { type: 'tool_use', id: 'toolu_1', name: 'my_tool', input: { a: 1 } } + ], + stop_reason: 'tool_use', + usage: { input_tokens: 1, output_tokens: 2 } + }; + + const openai = converter.toOpenAIResponse(claudeResponse, 'claude-sonnet-4-5'); + expect(openai.choices[0].message.content).toBe('t'); + expect(openai.choices[0].message.reasoning_content).toBe('r'); + expect(openai.choices[0].message.tool_calls).toHaveLength(1); + expect(openai.choices[0].message.tool_calls[0].function.name).toBe('my_tool'); + }); +}); + diff --git a/tests/kiro-thinking-parsing.test.js b/tests/kiro-thinking-parsing.test.js new file mode 100644 index 0000000..8baccd5 --- /dev/null +++ b/tests/kiro-thinking-parsing.test.js @@ -0,0 +1,40 @@ +import { KiroApiService } from '../src/providers/claude/claude-kiro.js'; + +describe('KiroApiService thinking tag parsing', () => { + let svc; + + beforeEach(() => { + svc = new KiroApiService({}); + }); + + test('splits ... into Claude content blocks', () => { + const blocks = svc._toClaudeContentBlocksFromKiroText('a\n\nhello'); + expect(blocks).toEqual([ + { type: 'thinking', thinking: 'a' }, + { type: 'text', text: 'hello' } + ]); + }); + + test('ignores quoted inside thinking content', () => { + const blocks = svc._toClaudeContentBlocksFromKiroText('about `` tag\n\nhi'); + expect(blocks).toEqual([ + { type: 'thinking', thinking: 'about `` tag' }, + { type: 'text', text: 'hi' } + ]); + }); + + test('does not treat without delimiter as a real end tag', () => { + const blocks = svc._toClaudeContentBlocksFromKiroText('ahello'); + expect(blocks).toEqual([ + { type: 'thinking', thinking: 'ahello' } + ]); + }); + + test('treats at buffer end as an end tag', () => { + const blocks = svc._toClaudeContentBlocksFromKiroText('a'); + expect(blocks).toEqual([ + { type: 'thinking', thinking: 'a' } + ]); + }); +}); + diff --git a/tests/openai-converter-thinking.test.js b/tests/openai-converter-thinking.test.js new file mode 100644 index 0000000..5ab6251 --- /dev/null +++ b/tests/openai-converter-thinking.test.js @@ -0,0 +1,55 @@ +import { OpenAIConverter } from '../src/converters/strategies/OpenAIConverter.js'; + +describe('OpenAIConverter thinking passthrough', () => { + let converter; + + beforeEach(() => { + converter = new OpenAIConverter(); + }); + + test('toClaudeRequest maps extra_body.anthropic.thinking enabled', () => { + const openaiRequest = { + model: 'claude-sonnet-4-5', + messages: [{ role: 'user', content: 'hi' }], + extra_body: { + anthropic: { + thinking: { type: 'enabled', budget_tokens: '10000' } + } + } + }; + + const claudeRequest = converter.toClaudeRequest(openaiRequest); + expect(claudeRequest.thinking).toEqual({ type: 'enabled', budget_tokens: 10000 }); + }); + + test('toClaudeRequest maps extra_body.anthropic.thinking adaptive', () => { + const openaiRequest = { + model: 'claude-sonnet-4-5', + messages: [{ role: 'user', content: 'hi' }], + extra_body: { + anthropic: { + thinking: { type: 'adaptive', effort: 'Medium' } + } + } + }; + + const claudeRequest = converter.toClaudeRequest(openaiRequest); + expect(claudeRequest.thinking).toEqual({ type: 'adaptive', effort: 'medium' }); + }); + + test('toClaudeRequest ignores invalid thinking objects', () => { + const openaiRequest = { + model: 'claude-sonnet-4-5', + messages: [{ role: 'user', content: 'hi' }], + extra_body: { + anthropic: { + thinking: 'enabled' + } + } + }; + + const claudeRequest = converter.toClaudeRequest(openaiRequest); + expect(claudeRequest.thinking).toBeUndefined(); + }); +}); + diff --git a/tests/openai-responses-converter-thinking.test.js b/tests/openai-responses-converter-thinking.test.js new file mode 100644 index 0000000..70290e0 --- /dev/null +++ b/tests/openai-responses-converter-thinking.test.js @@ -0,0 +1,27 @@ +import { OpenAIResponsesConverter } from '../src/converters/strategies/OpenAIResponsesConverter.js'; + +describe('OpenAIResponsesConverter reasoning -> thinking mapping', () => { + let converter; + + beforeEach(() => { + converter = new OpenAIResponsesConverter(); + }); + + test.each([ + ['low', 2048], + ['medium', 8192], + ['high', 20000], + ['unknown', 20000], + ])('toClaudeRequest maps reasoning.effort=%s to budget_tokens=%i', (effort, budgetTokens) => { + const responsesRequest = { + model: 'claude-sonnet-4-5', + max_output_tokens: 64, + reasoning: { effort }, + input: [{ role: 'user', content: 'hi' }] + }; + + const claudeRequest = converter.toClaudeRequest(responsesRequest); + expect(claudeRequest.thinking).toEqual({ type: 'enabled', budget_tokens: budgetTokens }); + }); +}); +