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 });
+ });
+});
+