feat(kiro): add request-side extended thinking support and structured reasoning outputs

This commit is contained in:
Codex 2026-02-11 12:26:03 +03:00
parent 54869893bf
commit 9f3040cf49
9 changed files with 490 additions and 99 deletions

View file

@ -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.

View file

@ -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;
export default ClaudeConverter;

View file

@ -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;
export default OpenAIConverter;

View file

@ -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
};
}

View file

@ -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: '<thinking>',
END_TAG: '</thinking>',
MODE_TAG: '<thinking_mode>',
MAX_LEN_TAG: '<max_thinking_length>',
EFFORT_TAG: '<thinking_effort>',
};
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 `</thinking>`
* 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 `<thinking_mode>enabled</thinking_mode><max_thinking_length>${budget}</max_thinking_length>`;
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 `<thinking_mode>enabled</thinking_mode><max_thinking_length>${budget}</max_thinking_length>`;
}
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 `<thinking_mode>adaptive</thinking_mode><thinking_effort>${normalizedEffort}</thinking_effort>`;
}
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 `<thinking>` 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 `<thinking>` (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 `<thinking>` 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 += `<thinking_mode>enabled</thinking_mode><max_thinking_length>${budget}</max_thinking_length>`;
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 += `<thinking_mode>enabled</thinking_mode><max_thinking_length>${budget}</max_thinking_length>`;
} 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 += `<thinking_mode>adaptive</thinking_mode><thinking_effort>${normalizedEffort}</thinking_effort>`;
}
}
// 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) {
}
}
}

View file

@ -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');
});
});

View file

@ -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 <thinking>...</thinking> into Claude content blocks', () => {
const blocks = svc._toClaudeContentBlocksFromKiroText('<thinking>a</thinking>\n\nhello');
expect(blocks).toEqual([
{ type: 'thinking', thinking: 'a' },
{ type: 'text', text: 'hello' }
]);
});
test('ignores quoted </thinking> inside thinking content', () => {
const blocks = svc._toClaudeContentBlocksFromKiroText('<thinking>about `</thinking>` tag</thinking>\n\nhi');
expect(blocks).toEqual([
{ type: 'thinking', thinking: 'about `</thinking>` tag' },
{ type: 'text', text: 'hi' }
]);
});
test('does not treat </thinking> without delimiter as a real end tag', () => {
const blocks = svc._toClaudeContentBlocksFromKiroText('<thinking>a</thinking>hello');
expect(blocks).toEqual([
{ type: 'thinking', thinking: 'a</thinking>hello' }
]);
});
test('treats </thinking> at buffer end as an end tag', () => {
const blocks = svc._toClaudeContentBlocksFromKiroText('<thinking>a</thinking>');
expect(blocks).toEqual([
{ type: 'thinking', thinking: 'a' }
]);
});
});

View file

@ -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();
});
});

View file

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