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
|
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
|
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
|
#### iFlow OAuth Configuration
|
||||||
1. **First Authorization**: In Web UI's "Configuration" or "Provider Pools" page, click the "Generate Authorization" button for iFlow
|
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
|
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
|
- Some accounts cannot access specific models due to quota or permission restrictions
|
||||||
- Need to assign different model access permissions to different accounts
|
- 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.
|
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) {
|
for (const item of msg.content) {
|
||||||
if (item && typeof item === 'object' && item.type === "tool_result") {
|
if (item && typeof item === 'object' && item.type === "tool_result") {
|
||||||
const toolUseId = item.tool_use_id || item.id || "";
|
const toolUseId = item.tool_use_id || item.id || "";
|
||||||
let contentStr = item.content || "";
|
const contentStr = String(item.content || "");
|
||||||
if (typeof contentStr === 'object') {
|
|
||||||
contentStr = JSON.stringify(contentStr);
|
|
||||||
} else {
|
|
||||||
contentStr = String(contentStr);
|
|
||||||
}
|
|
||||||
tempOpenAIMessages.push({
|
tempOpenAIMessages.push({
|
||||||
role: "tool",
|
role: "tool",
|
||||||
tool_call_id: toolUseId,
|
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
|
// 检查是否包含 tool_use
|
||||||
const hasToolUse = claudeResponse.content.some(block => block && block.type === '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);
|
message.content = this.processClaudeResponseContent(claudeResponse.content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (reasoningContent) {
|
||||||
|
message.reasoning_content = reasoningContent;
|
||||||
|
}
|
||||||
|
|
||||||
// 处理 finish_reason
|
// 处理 finish_reason
|
||||||
let finishReason = 'stop';
|
let finishReason = 'stop';
|
||||||
if (claudeResponse.stop_reason === 'end_turn') {
|
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') {
|
if (message.role === 'tool') {
|
||||||
// 工具结果消息
|
// 工具结果消息
|
||||||
let toolContent = message.content;
|
|
||||||
if (typeof toolContent === 'object' && toolContent !== null) {
|
|
||||||
toolContent = JSON.stringify(toolContent);
|
|
||||||
}
|
|
||||||
content.push({
|
content.push({
|
||||||
type: 'tool_result',
|
type: 'tool_result',
|
||||||
tool_use_id: message.tool_call_id,
|
tool_use_id: message.tool_call_id,
|
||||||
content: toolContent
|
content: safeParseJSON(message.content)
|
||||||
});
|
});
|
||||||
claudeMessages.push({ role: 'user', content: content });
|
claudeMessages.push({ role: 'user', content: content });
|
||||||
} else if (message.role === 'assistant' && (message.tool_calls?.length || message.function_calls?.length)) {
|
} 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);
|
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;
|
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
|
// 处理 reasoning effort
|
||||||
if (responsesRequest.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 = {
|
claudeRequest.thinking = {
|
||||||
type: 'enabled',
|
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';
|
import { getProviderPoolManager } from '../../services/service-manager.js';
|
||||||
|
|
||||||
const KIRO_THINKING = {
|
const KIRO_THINKING = {
|
||||||
|
MIN_BUDGET_TOKENS: 1024,
|
||||||
MAX_BUDGET_TOKENS: 24576,
|
MAX_BUDGET_TOKENS: 24576,
|
||||||
DEFAULT_BUDGET_TOKENS: 20000,
|
DEFAULT_BUDGET_TOKENS: 20000,
|
||||||
START_TAG: '<thinking>',
|
START_TAG: '<thinking>',
|
||||||
END_TAG: '</thinking>',
|
END_TAG: '</thinking>',
|
||||||
MODE_TAG: '<thinking_mode>',
|
MODE_TAG: '<thinking_mode>',
|
||||||
MAX_LEN_TAG: '<max_thinking_length>',
|
MAX_LEN_TAG: '<max_thinking_length>',
|
||||||
|
EFFORT_TAG: '<thinking_effort>',
|
||||||
};
|
};
|
||||||
|
|
||||||
const KIRO_CONSTANTS = {
|
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 - 要搜索的文本
|
* @param {string} text - 要搜索的文本
|
||||||
|
|
@ -743,18 +781,32 @@ async saveCredentialsToFile(filePath, newData) {
|
||||||
value = KIRO_THINKING.DEFAULT_BUDGET_TOKENS;
|
value = KIRO_THINKING.DEFAULT_BUDGET_TOKENS;
|
||||||
}
|
}
|
||||||
value = Math.floor(value);
|
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);
|
return Math.min(value, KIRO_THINKING.MAX_BUDGET_TOKENS);
|
||||||
}
|
}
|
||||||
|
|
||||||
_generateThinkingPrefix(thinking) {
|
_generateThinkingPrefix(thinking) {
|
||||||
if (!thinking || thinking.type !== 'enabled') return null;
|
if (!thinking || typeof thinking !== 'object') return null;
|
||||||
const budget = this._normalizeThinkingBudgetTokens(thinking.budget_tokens);
|
const type = String(thinking.type || '').toLowerCase().trim();
|
||||||
return `<thinking_mode>enabled</thinking_mode><max_thinking_length>${budget}</max_thinking_length>`;
|
|
||||||
|
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) {
|
_hasThinkingPrefix(text) {
|
||||||
if (!text) return false;
|
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) {
|
_toClaudeContentBlocksFromKiroText(content) {
|
||||||
|
|
@ -768,8 +820,14 @@ async saveCredentialsToFile(filePath, newData) {
|
||||||
|
|
||||||
const before = raw.slice(0, startPos);
|
const before = raw.slice(0, startPos);
|
||||||
let rest = raw.slice(startPos + KIRO_THINKING.START_TAG.length);
|
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 thinking = '';
|
||||||
let after = '';
|
let after = '';
|
||||||
if (endPosInRest === -1) {
|
if (endPosInRest === -1) {
|
||||||
|
|
@ -780,11 +838,12 @@ async saveCredentialsToFile(filePath, newData) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (after.startsWith('\n\n')) after = after.slice(2);
|
if (after.startsWith('\n\n')) after = after.slice(2);
|
||||||
|
if (isWhitespaceOnly(after)) after = '';
|
||||||
|
|
||||||
const blocks = [];
|
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 });
|
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;
|
return blocks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -905,62 +964,35 @@ async saveCredentialsToFile(filePath, newData) {
|
||||||
};
|
};
|
||||||
toolsContext = { tools: [placeholderTool] };
|
toolsContext = { tools: [placeholderTool] };
|
||||||
} else {
|
} else {
|
||||||
const MAX_DESCRIPTION_LENGTH = 9216;
|
const MAX_DESCRIPTION_LENGTH = 9216;
|
||||||
|
|
||||||
let truncatedCount = 0;
|
let truncatedCount = 0;
|
||||||
const kiroTools = filteredTools
|
const kiroTools = filteredTools.map(tool => {
|
||||||
.filter(tool => {
|
let desc = tool.description || "";
|
||||||
// 过滤掉描述为空的工具
|
const originalLength = desc.length;
|
||||||
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 || {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
if (truncatedCount > 0) {
|
if (desc.length > MAX_DESCRIPTION_LENGTH) {
|
||||||
logger.info(`[Kiro] Truncated ${truncatedCount} tool description(s) to max ${MAX_DESCRIPTION_LENGTH} chars`);
|
desc = desc.substring(0, MAX_DESCRIPTION_LENGTH) + "...";
|
||||||
|
truncatedCount++;
|
||||||
|
logger.info(`[Kiro] Truncated tool '${tool.name}' description: ${originalLength} -> ${desc.length} chars`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查过滤后是否还有有效工具
|
return {
|
||||||
if (kiroTools.length === 0) {
|
toolSpecification: {
|
||||||
logger.info('[Kiro] All tools were filtered out (empty descriptions), adding placeholder tool');
|
name: tool.name,
|
||||||
const placeholderTool = {
|
description: desc,
|
||||||
toolSpecification: {
|
inputSchema: {
|
||||||
name: "no_tool_available",
|
json: tool.input_schema || {}
|
||||||
description: "This is a placeholder tool when no other tools are available. It does nothing.",
|
|
||||||
inputSchema: {
|
|
||||||
json: {
|
|
||||||
type: "object",
|
|
||||||
properties: {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
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 {
|
} else {
|
||||||
// tools 为空或长度为 0 时,自动添加一个占位工具
|
// tools 为空或长度为 0 时,自动添加一个占位工具
|
||||||
|
|
@ -1734,7 +1766,13 @@ async saveCredentialsToFile(filePath, newData) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { responseText, toolCalls } = this._processApiResponse(response);
|
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) {
|
} catch (error) {
|
||||||
logger.error('[Kiro] Error in generateContent:', error);
|
logger.error('[Kiro] Error in generateContent:', error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -2094,17 +2132,22 @@ async saveCredentialsToFile(filePath, newData) {
|
||||||
let contextUsagePercentage = null;
|
let contextUsagePercentage = null;
|
||||||
const messageId = `${uuidv4()}`;
|
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 = {
|
const streamState = {
|
||||||
thinkingRequested,
|
thinkingRequested,
|
||||||
buffer: '',
|
buffer: '',
|
||||||
|
pendingTextBeforeThinking: '',
|
||||||
inThinking: false,
|
inThinking: false,
|
||||||
thinkingExtracted: false,
|
thinkingExtracted: false,
|
||||||
thinkingBlockIndex: null,
|
thinkingBlockIndex: null,
|
||||||
textBlockIndex: null,
|
textBlockIndex: null,
|
||||||
nextBlockIndex: 0,
|
nextBlockIndex: 0,
|
||||||
stoppedBlocks: new Set(),
|
stoppedBlocks: new Set(),
|
||||||
|
stripThinkingLeadingNewline: false,
|
||||||
|
stripTextLeadingNewlinesAfterThinking: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const ensureBlockStart = (blockType) => {
|
const ensureBlockStart = (blockType) => {
|
||||||
|
|
@ -2214,24 +2257,58 @@ async saveCredentialsToFile(filePath, newData) {
|
||||||
const startPos = findRealTag(streamState.buffer, KIRO_THINKING.START_TAG);
|
const startPos = findRealTag(streamState.buffer, KIRO_THINKING.START_TAG);
|
||||||
if (startPos !== -1) {
|
if (startPos !== -1) {
|
||||||
const before = streamState.buffer.slice(0, startPos);
|
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.buffer = streamState.buffer.slice(startPos + KIRO_THINKING.START_TAG.length);
|
||||||
streamState.inThinking = true;
|
streamState.inThinking = true;
|
||||||
|
streamState.stripThinkingLeadingNewline = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const safeLen = Math.max(0, streamState.buffer.length - KIRO_THINKING.START_TAG.length);
|
const safeLen = Math.max(0, streamState.buffer.length - KIRO_THINKING.START_TAG.length);
|
||||||
if (safeLen > 0) {
|
if (safeLen > 0) {
|
||||||
const safeText = streamState.buffer.slice(0, safeLen);
|
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);
|
streamState.buffer = streamState.buffer.slice(safeLen);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (streamState.inThinking) {
|
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) {
|
if (endPos !== -1) {
|
||||||
const thinkingPart = streamState.buffer.slice(0, endPos);
|
const thinkingPart = streamState.buffer.slice(0, endPos);
|
||||||
if (thinkingPart) events.push(...createThinkingDeltaEvents(thinkingPart));
|
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.buffer = streamState.buffer.slice(endPos + KIRO_THINKING.END_TAG.length);
|
||||||
streamState.inThinking = false;
|
streamState.inThinking = false;
|
||||||
streamState.thinkingExtracted = true;
|
streamState.thinkingExtracted = true;
|
||||||
|
streamState.stripThinkingLeadingNewline = false;
|
||||||
|
|
||||||
events.push(...createThinkingDeltaEvents(""));
|
events.push(...createThinkingDeltaEvents(""));
|
||||||
events.push(...stopBlock(streamState.thinkingBlockIndex));
|
events.push(...stopBlock(streamState.thinkingBlockIndex));
|
||||||
|
|
||||||
if (streamState.buffer.startsWith('\n\n')) {
|
// Strip '\n\n' after the end tag once we switch back to text (may arrive in next chunk).
|
||||||
streamState.buffer = streamState.buffer.slice(2);
|
streamState.stripTextLeadingNewlinesAfterThinking = true;
|
||||||
}
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2259,8 +2336,13 @@ async saveCredentialsToFile(filePath, newData) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (streamState.thinkingExtracted) {
|
if (streamState.thinkingExtracted) {
|
||||||
const rest = streamState.buffer;
|
let rest = streamState.buffer;
|
||||||
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));
|
if (rest) events.push(...createTextDeltaEvents(rest));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -2341,18 +2423,33 @@ async saveCredentialsToFile(filePath, newData) {
|
||||||
currentToolCall = null;
|
currentToolCall = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (thinkingRequested && streamState.buffer) {
|
if (thinkingRequested && (streamState.inThinking || streamState.buffer || streamState.pendingTextBeforeThinking)) {
|
||||||
if (streamState.inThinking) {
|
if (streamState.inThinking) {
|
||||||
logger.warn('[Kiro] Incomplete thinking tag at stream end');
|
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));
|
yield* pushEvents(createThinkingDeltaEvents(streamState.buffer));
|
||||||
streamState.buffer = '';
|
streamState.buffer = '';
|
||||||
yield* pushEvents(createThinkingDeltaEvents(""));
|
yield* pushEvents(createThinkingDeltaEvents(""));
|
||||||
yield* pushEvents(stopBlock(streamState.thinkingBlockIndex));
|
yield* pushEvents(stopBlock(streamState.thinkingBlockIndex));
|
||||||
} else if (!streamState.thinkingExtracted) {
|
} 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 = '';
|
streamState.buffer = '';
|
||||||
} else {
|
} 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 = '';
|
streamState.buffer = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2475,9 +2572,17 @@ async saveCredentialsToFile(filePath, newData) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count thinking prefix tokens if thinking is enabled
|
// Count thinking prefix tokens if thinking is enabled
|
||||||
if (requestBody.thinking?.type === 'enabled') {
|
if (requestBody.thinking?.type && typeof requestBody.thinking.type === 'string') {
|
||||||
const budget = this._normalizeThinkingBudgetTokens(requestBody.thinking.budget_tokens);
|
const t = requestBody.thinking.type.toLowerCase().trim();
|
||||||
allText += `<thinking_mode>enabled</thinking_mode><max_thinking_length>${budget}</max_thinking_length>`;
|
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
|
// Count all messages tokens
|
||||||
|
|
@ -2627,9 +2732,31 @@ async saveCredentialsToFile(filePath, newData) {
|
||||||
} else {
|
} else {
|
||||||
// Non-streaming response (full message object)
|
// Non-streaming response (full message object)
|
||||||
const contentArray = [];
|
const contentArray = [];
|
||||||
let stopReason = "end_turn";
|
|
||||||
let outputTokens = 0;
|
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) {
|
if (toolCalls && toolCalls.length > 0) {
|
||||||
for (const tc of toolCalls) {
|
for (const tc of toolCalls) {
|
||||||
let inputObject;
|
let inputObject;
|
||||||
|
|
@ -2651,13 +2778,7 @@ async saveCredentialsToFile(filePath, newData) {
|
||||||
});
|
});
|
||||||
outputTokens += this.countTextTokens(tc.function.arguments);
|
outputTokens += this.countTextTokens(tc.function.arguments);
|
||||||
}
|
}
|
||||||
stopReason = "tool_use"; // Set stop_reason to "tool_use" when toolCalls exist
|
stopReason = "tool_use";
|
||||||
} else if (content) {
|
|
||||||
contentArray.push({
|
|
||||||
type: "text",
|
|
||||||
text: content
|
|
||||||
});
|
|
||||||
outputTokens += this.countTextTokens(content);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
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