Merge pull request #320 from Asoubra12/pr/kiro-extended-thinking-slim
feat(kiro): add extended thinking passthrough and structured reasoning outputs
This commit is contained in:
commit
f16f972d97
9 changed files with 490 additions and 99 deletions
71
README.md
71
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
@ -2187,4 +2197,4 @@ export class ClaudeConverter extends BaseConverter {
|
|||
}
|
||||
}
|
||||
|
||||
export default ClaudeConverter;
|
||||
export default ClaudeConverter;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -394,9 +394,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
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
@ -121,6 +123,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 - 要搜索的文本
|
||||
|
|
@ -743,18 +781,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) {
|
||||
|
|
@ -768,8 +820,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) {
|
||||
|
|
@ -780,11 +838,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;
|
||||
}
|
||||
|
||||
|
|
@ -905,62 +964,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 时,自动添加一个占位工具
|
||||
|
|
@ -1734,7 +1766,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 error;
|
||||
|
|
@ -2094,17 +2132,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) => {
|
||||
|
|
@ -2214,24 +2257,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));
|
||||
|
|
@ -2239,13 +2316,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;
|
||||
}
|
||||
|
||||
|
|
@ -2259,8 +2336,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;
|
||||
}
|
||||
|
|
@ -2341,18 +2423,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 = '';
|
||||
}
|
||||
}
|
||||
|
|
@ -2475,9 +2572,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
|
||||
|
|
@ -2627,9 +2732,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;
|
||||
|
|
@ -2651,13 +2778,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 {
|
||||
|
|
@ -2894,4 +3015,3 @@ async saveCredentialsToFile(filePath, newData) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
43
tests/claude-converter-thinking.test.js
Normal file
43
tests/claude-converter-thinking.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
|
||||
40
tests/kiro-thinking-parsing.test.js
Normal file
40
tests/kiro-thinking-parsing.test.js
Normal 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' }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
55
tests/openai-converter-thinking.test.js
Normal file
55
tests/openai-converter-thinking.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
|
||||
27
tests/openai-responses-converter-thinking.test.js
Normal file
27
tests/openai-responses-converter-thinking.test.js
Normal 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 });
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in a new issue