Merge origin/master (v0.5.75) into gitea/new_feature

Resolve conflicts:
- package.json / cli/package.json: take 0.5.75
- .gitignore: union both sides (upstream 9router-*/temp files + local state dirs)
- CHANGELOG.md: keep both blocks, v0.5.75 above v0.5.70
- nonStreamingHandler.js: merge imports (unwrapClineEnvelope +
  tokensForDetail/shouldPersistRequestDetail); drop dead appendRequestLog
- providers/[id]/page.js: union useState blocks (compatible-model states
  + importingClineModels)

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
2026-09-17 14:14:43 +07:00
113 changed files with 6634 additions and 482 deletions

View File

@@ -14,6 +14,9 @@ const STRIP_RULES = [
{ provider: "github", match: (m) => /claude/i.test(m) && !/claude.*(opus|sonnet).*4\.6/i.test(m), drop: ["thinking", "reasoning_effort"] },
// Cloudflare Workers AI: content must be plain string, rejects OpenAI content-part array (#1926)
{ provider: "cloudflare-ai", flattenContent: true },
// MiMo Desktop Preview models (account-service route): content must be plain string,
// rejects OpenAI content-part array. Cloud models keep their parts (mimo-v2-omni is multi-modal).
{ provider: "xiaomi-mimo", match: /preview/i, flattenContent: true },
{ provider: "volcengine-ark", match: /glm-5/i, clampToModelMaxOutput: true },
// VolcEngine Ark caps the Kimi family at max_tokens <= 32768, but the model's
// advertised ceiling is far higher (Kimi-K2.7-Code resolves to maxOutput 262144),

View File

@@ -1,5 +1,7 @@
// Tool call helper functions for translator
import { FORMATS } from "../formats.js";
// Anthropic tool_use.id must match: ^[a-zA-Z0-9_-]+$
const TOOL_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
@@ -165,3 +167,16 @@ export function defaultClaudeToolType(tools) {
return tools.map(tool => tool?.type ? tool : { ...tool, type: "custom" });
}
// Whether Claude-format tools need explicit `type` defaulting before dispatch.
// Only gateways that declare the `requireClaudeToolType` quirk (MiniMax) reject typeless
// tools. Applying the default globally breaks Claude-format endpoints that only accept the
// legacy typeless tool shape — DeepSeek's Anthropic-compatible endpoint answers HTTP 400
// "unknown variant `custom`" and every Claude Code request routed there fails (#3905).
export function shouldDefaultClaudeToolType(provider, finalFormat, tools, PROVIDERS) {
return (
finalFormat === FORMATS.CLAUDE
&& Array.isArray(tools)
&& PROVIDERS?.[provider]?.quirks?.requireClaudeToolType === true
);
}

View File

@@ -27,6 +27,14 @@ export function lastCacheableToolIndex(tools) {
// Check if message has valid non-empty content
export function hasValidContent(msg) {
if (typeof msg.content === "string" && msg.content.trim()) return true;
if (msg.content && typeof msg.content === "object" && !Array.isArray(msg.content)) {
const block = msg.content;
return !!((block.type === CLAUDE_BLOCK.TEXT && block.text?.trim()) ||
block.type === CLAUDE_BLOCK.TOOL_USE ||
block.type === CLAUDE_BLOCK.TOOL_RESULT ||
block.type === CLAUDE_BLOCK.IMAGE ||
block.type === CLAUDE_BLOCK.DOCUMENT);
}
if (Array.isArray(msg.content)) {
return msg.content.some(block =>
(block.type === CLAUDE_BLOCK.TEXT && block.text?.trim()) ||
@@ -38,6 +46,60 @@ export function hasValidContent(msg) {
}
return false;
}
// Content may arrive as a single content block object (spec allows string | array;
// some clients send the bare object). Wrap it as a one-block array and strip any
// client-placed cache_control: a bare-object marker must never survive
// normalization, on any path, guard or no guard.
function normalizeMessageContent(msg) {
const c = msg?.content;
if (c && typeof c === "object" && !Array.isArray(c)) {
delete c.cache_control;
msg.content = [c];
}
return msg;
}
// Total blocks carrying cache_control across system, tools, and messages — the
// upstream Messages API allows at most 4 markers per request.
function countCacheControlBlocks(body) {
let n = 0;
if (Array.isArray(body?.system)) for (const b of body.system) if (b?.cache_control) n++;
if (Array.isArray(body?.tools)) for (const t of body.tools) if (t?.cache_control) n++;
if (Array.isArray(body?.messages)) {
for (const m of body.messages) {
if (Array.isArray(m?.content)) {
for (const b of m.content) if (b?.cache_control) n++;
} else if (m?.content && typeof m.content === "object" && m.content.cache_control) n++;
}
}
return n;
}
// Trim every marker past the 4-marker budget. The head anchors (last system
// block, last cacheable tool) are held; the remaining slots go to the tail-most
// of the other markers in document order. A plain "keep the last 4 in document
// order" rule would drop the head anchors first — they lead document order, yet
// they are exactly what re-anchoring exists to pin.
function capCacheControlBlocks(body) {
const isHead = (b) => {
const sys = Array.isArray(body?.system) ? body.system : [];
if (sys.length && sys[sys.length - 1] === b) return true;
const tools = Array.isArray(body?.tools) ? body.tools : [];
const lastTool = lastCacheableToolIndex(tools);
return lastTool >= 0 && tools[lastTool] === b;
};
const marked = [];
if (Array.isArray(body?.system)) for (const b of body.system) if (b?.cache_control) marked.push(b);
if (Array.isArray(body?.tools)) for (const t of body.tools) if (t?.cache_control) marked.push(t);
if (Array.isArray(body?.messages)) {
for (const m of body.messages) {
if (Array.isArray(m?.content)) for (const b of m.content) if (b?.cache_control) marked.push(b);
}
}
const head = marked.filter(isHead);
const rest = marked.filter(b => !isHead(b));
const keep = Math.max(0, 4 - head.length);
for (const b of rest.slice(0, Math.max(0, rest.length - keep))) delete b.cache_control;
}
// Fix tool_use/tool_result ordering for Claude API
// 1. Assistant message with tool_use: remove text AFTER tool_use (Claude doesn't allow)
@@ -136,8 +198,9 @@ function hasForeignServerToolUseId(block) {
// Newer Cowork/Claude Code clients emit beta-only shapes that OAuth endpoints reject:
// 1. thinking.type "adaptive" → unsupported on Haiku
// 2. output_config.effort → unsupported on Haiku
// 3. role "system" messages (mid-conversation-system beta) → only top-level system is allowed
// 4. server_tool_use blocks carrying a foreign (non-srvtoolu_) id → rejected outright
// 3. bare content-block objects (content: {block} instead of [{block}]) → wrapped first
// 4. role "system" messages (mid-conversation-system beta) → only top-level system is allowed
// 5. server_tool_use blocks carrying a foreign (non-srvtoolu_) id → rejected outright
export function normalizeClaudePassthrough(body, model = "") {
if (!body || typeof body !== "object") return body;
@@ -152,7 +215,15 @@ export function normalizeClaudePassthrough(body, model = "") {
if (Object.keys(body.output_config).length === 0) delete body.output_config;
}
// 2. Fold mid-conversation system messages into the neighbouring turn.
// 3. Wrap bare content-block objects as one-element arrays before folding.
// Some clients send content: {block} instead of content: [{block}]; the
// mid-conversation-system fold below assumes the array shape, so it must
// run first — a bare-object neighbor would otherwise be zeroed to [].
if (Array.isArray(body.messages)) {
for (const msg of body.messages) normalizeMessageContent(msg);
}
// 4. Fold mid-conversation system messages into the neighbouring turn.
// Hoisting them into body.system would insert volatile content (token counters,
// reminders) ahead of the whole conversation and invalidate the prefix cache on
// every request. Folding in place keeps the cached prefix stable.
@@ -186,7 +257,7 @@ export function normalizeClaudePassthrough(body, model = "") {
body.messages = messages;
}
// 3. Drop thinking blocks whose signature is not Claude's (combo mixes models,
// 5. Drop thinking blocks whose signature is not Claude's (combo mixes models,
// so foreign signatures leak into history and Anthropic rejects them).
const thinkingEnabled = body.thinking?.type === "enabled";
const droppedServerToolUseIds = new Set();
@@ -233,7 +304,7 @@ export function normalizeClaudePassthrough(body, model = "") {
}
}
// 5. Drop empty text blocks and any message left with no content at all.
// 6. Drop empty text blocks and any message left with no content at all.
// Anthropic rejects `messages.N.content` blocks with empty text (400
// "text content blocks must be non-empty"); a message whose blocks were all
// stripped above must be dropped, not padded with an empty placeholder.
@@ -271,7 +342,22 @@ function markLastCacheableBlock(msg) {
// (normalize, tool dedupe, token savers) — otherwise the anchor drifts off the tail.
export function anchorClaudeCache(body) {
if (!body || typeof body !== "object") return body;
if (Array.isArray(body.messages)) {
for (const msg of body.messages) normalizeMessageContent(msg);
}
// Invalid markers first, whatever the budget: Anthropic rejects a tool that
// carries BOTH defer_loading and cache_control (#3567). The re-anchor path
// below strips them anyway; the over-budget early return used to forward
// them untouched.
if (Array.isArray(body.tools)) {
for (const t of body.tools) {
if (t?.defer_loading === true) delete t.cache_control;
}
}
// Head anchors first, before any budget guard: the 1h TTL on system/tools is
// the point of re-anchoring, and skipping it because the client spent its
// budget would silently downgrade a cache hit to the 5m default.
if (Array.isArray(body.system)) {
const last = body.system.length - 1;
body.system.forEach((block, i) => {
@@ -289,6 +375,15 @@ export function anchorClaudeCache(body) {
});
}
// Budget guard AFTER the head anchors: with the last system block and last
// tool pinned, at most 2 slots remain. At >= 4 markers the client has spent
// the rest of the budget and every remaining marker is itself a valid
// breakpoint — re-anchoring the tail could only exceed 4, so trim instead.
if (countCacheControlBlocks(body) >= 4) {
capCacheControlBlocks(body);
return body;
}
if (Array.isArray(body.messages)) {
let anchored = null;
for (let i = body.messages.length - 1; i >= 0; i--) {
@@ -368,6 +463,7 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne
// Pass 1: remove cache_control + filter empty messages
for (let i = 0; i < len; i++) {
const msg = body.messages[i];
normalizeMessageContent(msg);
// Remove cache_control from content blocks
if (Array.isArray(msg.content)) {
@@ -461,8 +557,21 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne
// Strip built-in tools (e.g. web_search_20250305) and normalize to Anthropic-native shape
// (drop `type` field, fold `function.{name,description,parameters}`) for non-Anthropic providers
if (provider !== "claude") {
// Provider-specific whitelist of Anthropic tool `type` values that the
// upstream actually accepts. When the provider declares it
// (e.g. DeepSeek — only web_search_*), keep only listed types; otherwise
// keep the prior behaviour of dropping every non-function tool, which is
// correct for OpenAI-compatible targets reached through this Claude-format
// pass (their tools get normalized below to function-style).
const supportedTypes = PROVIDERS[provider]?.quirks?.claudeSupportedToolTypes;
const hasWhitelist = Array.isArray(supportedTypes);
body.tools = body.tools
.filter(tool => !tool.type || tool.type === "function")
.filter(tool => {
const t = tool?.type;
if (!t || t === "function") return true;
if (hasWhitelist) return supportedTypes.includes(t);
return false;
})
.map(tool => {
if (tool.function) {
return {
@@ -471,6 +580,13 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne
input_schema: tool.function.parameters,
};
}
// When the provider declared a supportedToolTypes whitelist, keep
// the surviving tools' `type` field intact — the upstream
// Anthropic-compatible endpoint (e.g. DeepSeek) requires it to
// route built-ins like web_search_* correctly. Without a
// whitelist, preserve prior behaviour and strip `type` so the
// tool is normalized to plain Anthropic shape.
if (hasWhitelist) return tool;
const { type, ...rest } = tool;
return rest;
});

View File

@@ -432,3 +432,21 @@ export function cleanJSONSchemaForAntigravity(schema) {
return cleaned;
}
// Merge adjacent same-role messages, strip empty parts, ensure initial user turn
export function normalizeGeminiContents(contents) {
const out = [];
for (const c of contents || []) {
if (!c?.role || !Array.isArray(c.parts)) continue;
const parts = c.parts.filter(p => p && Object.keys(p).length > 0);
if (parts.length === 0) continue;
const last = out.at(-1);
if (last?.role === c.role) last.parts.push(...parts);
else out.push({ ...c, parts: [...parts] });
}
if (out.length > 0 && out[0].role !== "user") {
out.unshift({ role: "user", parts: [{ text: "..." }] });
}
return out;
}

View File

@@ -242,9 +242,9 @@ export function claudeToKiroRequest(model, body, stream, credentials) {
? (credentials?.providerSpecificData?.profileArn || "")
: (credentials?.providerSpecificData?.profileArn || resolveDefaultProfileArn(authMethod));
// Kiro CLI/KAS sends system prompt as top-level `systemPrompt`. Keep a
// content fallback too because the CodeWhisperer surface does not always
// enforce top-level systemPrompt for direct calls.
// The system prompt travels inside the first user turn's content (contentPrefix):
// the CodeWhisperer surface rejects a top-level `systemPrompt` with
// 400 REQUEST_BODY_INVALID, so the value below is only a replay cache key.
const timestamp = new Date().toISOString();
const systemPromptParts = [];
if (thinkingBudget !== null && !usesNativeGptEffort) {
@@ -316,14 +316,11 @@ export function claudeToKiroRequest(model, body, stream, credentials) {
conversationState: {
chatTriggerType: "MANUAL",
conversationId,
agentContinuationId: continuationId,
agentTaskType: "vibe",
currentMessage: {
userInputMessage,
},
history: canonical.history,
},
agentMode: "vibe",
};
if (profileArn) payload.profileArn = profileArn;

View File

@@ -142,6 +142,13 @@ function systemReminderText(content) {
// Convert single Claude message - returns single message or array of messages
function convertClaudeMessage(msg) {
// Some clients send content as a single block object; normalize to the
// one-element array every branch below (the system-reminder fold included)
// expects. Must run BEFORE the role branch: systemReminderText only reads
// arrays and strings, so a bare-object system turn was dropped outright.
if (msg.content && typeof msg.content === "object" && !Array.isArray(msg.content)) {
msg.content = [msg.content];
}
// Mid-conversation system message -> user (per Anthropic placement rules)
if (msg.role === ROLE.SYSTEM) {
const text = systemReminderText(msg.content);

View File

@@ -15,7 +15,8 @@ import {
generateRequestId,
generateSessionId,
generateProjectId,
cleanJSONSchemaForAntigravity
cleanJSONSchemaForAntigravity,
normalizeGeminiContents
} from "../formats/gemini.js";
import { deriveSessionId, toNumericSessionId } from "../../utils/sessionManager.js";
import { ROLE, GEMINI_ROLE, OPENAI_BLOCK, CLAUDE_BLOCK } from "../schema/index.js";
@@ -35,17 +36,6 @@ function sanitizeGeminiFunctionName(name) {
return sanitized.substring(0, 64);
}
function normalizeGeminiContents(contents) {
const out = [];
for (const c of contents || []) {
if (!c?.role || !Array.isArray(c.parts) || c.parts.length === 0) continue;
const last = out.at(-1);
if (last?.role === c.role) last.parts.push(...c.parts);
else out.push({ ...c, parts: [...c.parts] });
}
return out;
}
// Core: Convert OpenAI request to Gemini format (base for all variants)
function openaiToGeminiBase(model, body, stream, signature = DEFAULT_THINKING_AG_SIGNATURE, sessionId = null) {
const result = {
@@ -163,12 +153,14 @@ function openaiToGeminiBase(model, body, stream, signature = DEFAULT_THINKING_AG
}
// Check if there are actual tool responses in the next messages
const hasActualResponses = toolCallIds.some(fid => toolResponses[fid]);
const isIntermediate = i < body.messages.length - 1;
const hasActualResponses = toolCallIds.some(fid => toolResponses[fid] !== undefined);
if (hasActualResponses) {
if (hasActualResponses || isIntermediate) {
const toolParts = [];
for (const fid of toolCallIds) {
if (!toolResponses[fid]) continue;
let resp = toolResponses[fid];
if (resp === undefined) resp = "";
let name = tcID2Name[fid];
if (!name) {
@@ -180,7 +172,6 @@ function openaiToGeminiBase(model, body, stream, signature = DEFAULT_THINKING_AG
}
}
let resp = toolResponses[fid];
let parsedResp = tryParseJSON(resp);
if (parsedResp === null) {
parsedResp = { result: resp };

View File

@@ -340,9 +340,9 @@ export function openaiToKiroRequest(model, body, stream, credentials) {
const timestamp = new Date().toISOString();
// Kiro CLI/KAS sends these as top-level systemPrompt. Keep a content fallback
// too because the CodeWhisperer surface does not always enforce top-level
// systemPrompt for direct calls.
// The system prompt travels inside the first user turn's content (contentPrefix):
// the CodeWhisperer surface rejects a top-level `systemPrompt` with
// 400 REQUEST_BODY_INVALID, so the value below is only a replay cache key.
const systemPromptParts = [];
if (thinkingBudget !== null && !usesNativeGptEffort) {
systemPromptParts.push(buildThinkingSystemPrefix(thinkingBudget));
@@ -397,8 +397,6 @@ export function openaiToKiroRequest(model, body, stream, credentials) {
conversationState: {
chatTriggerType: "MANUAL",
conversationId,
agentContinuationId: continuationId,
agentTaskType: "vibe",
currentMessage: {
userInputMessage: {
content: replayCurrent.content || "",
@@ -414,7 +412,6 @@ export function openaiToKiroRequest(model, body, stream, credentials) {
},
history: canonical.history
},
agentMode: "vibe",
};
if (profileArn) {