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

Resolve conflicts:
- streamingHandler.js: merge buildStreamErrorBytes onAbortTerminal + local shouldPersistRequestDetail & streamStatusForContent
- capabilities.js: preserve server-injected user-asserted caps and models.dev catalog lookup; wire CommandCode /alpha/generate caps inside resolve()
- commandcode.js (services/usage): adopt upstream whoami + billing credits/subscriptions with 5h/weekly rate windows and plan caps
- openai-to-commandcode.js: merge toNativeImageBlock (data-URI & http(s) support) and assistant reasoning_content preservation
- commandcode-to-openai.js: adopt upstream mid-stream error throw for clean retry and abortion
- tests: sync commandcode test suite and exclude .next from vitest config
This commit is contained in:
2026-09-22 10:08:58 +07:00
95 changed files with 4059 additions and 1016 deletions

View File

@@ -34,6 +34,7 @@ import { ROLE, CLAUDE_BLOCK } from "../schema/index.js";
import {
canonicalizeKiroConversation,
normalizeKiroToolSpecs,
kiroEmptyUserContent,
} from "../concerns/kiroConversation.js";
/**
@@ -53,7 +54,8 @@ function convertClaudeMessagesToKiro(messages, model) {
const flushPending = () => {
if (currentRole === ROLE.USER) {
const content = pendingUserContent.join("\n\n").trim() || "continue";
const content = pendingUserContent.join("\n\n").trim()
|| kiroEmptyUserContent(pendingToolResults.length > 0);
const userMsg = { userInputMessage: { content, modelId: model } };
if (pendingImages.length > 0) {
@@ -97,11 +99,21 @@ function convertClaudeMessagesToKiro(messages, model) {
if (typeof block.content === "string") {
resultContent = block.content;
} else if (Array.isArray(block.content)) {
// Images a tool returned (screenshots) ride along as user images;
// Kiro tool results are text-only.
let hasImage = false;
for (const c of block.content) {
if (c?.type === CLAUDE_BLOCK.IMAGE && c.source?.type === "base64") {
hasImage = true;
const imageType = c.source.media_type || DEFAULT_IMAGE_MIME;
pendingImages.push({ format: imageType.split("/")[1] || imageType, source: { bytes: c.source.data } });
}
}
resultContent =
block.content
.filter((c) => c.type === CLAUDE_BLOCK.TEXT)
.map((c) => c.text)
.join("\n") || JSON.stringify(block.content);
.join("\n") || (hasImage ? "(image attached)" : JSON.stringify(block.content));
} else if (block.content) {
resultContent = JSON.stringify(block.content);
}
@@ -341,6 +353,13 @@ export function claudeToKiroRequest(model, body, stream, credentials) {
enumerable: false,
});
// Kiro tool specs get sanitized names (`mcp__a__b` → `mcp_a_b`); keep the
// reverse map so tool calls stream back under the client's own names.
const restoredToolNames = new Map();
for (const [original, sanitized] of nameMap) {
if (original !== sanitized) restoredToolNames.set(sanitized, original);
}
if (restoredToolNames.size) payload._toolNameMap = restoredToolNames;
return payload;
}

View File

@@ -196,25 +196,41 @@ function convertClaudeMessage(msg) {
});
break;
case CLAUDE_BLOCK.TOOL_RESULT:
case CLAUDE_BLOCK.TOOL_RESULT: {
let resultContent = "";
const resultImages = [];
if (typeof block.content === "string") {
resultContent = block.content;
} else if (Array.isArray(block.content)) {
resultContent = block.content
.filter(c => c.type === CLAUDE_BLOCK.TEXT)
.map(c => c.text)
.join("\n") || JSON.stringify(block.content);
for (const c of block.content) {
if (c?.type === CLAUDE_BLOCK.IMAGE && c.source?.type === "base64") {
resultImages.push({
type: OPENAI_BLOCK.IMAGE_URL,
image_url: { url: encodeDataUri(c.source.media_type, c.source.data) }
});
}
}
const textOnly = block.content.filter(c => c?.type === CLAUDE_BLOCK.TEXT);
resultContent = textOnly.map(c => c.text).join("\n")
|| (resultImages.length ? "" : JSON.stringify(block.content));
} else if (block.content) {
resultContent = JSON.stringify(block.content);
}
toolResults.push({
role: ROLE.TOOL,
tool_call_id: block.tool_use_id,
content: resultContent
});
// The OpenAI tool role is text-only, so a screenshot or any other image a
// tool returned would otherwise vanish. Hand it to the model in the user
// turn that follows the tool messages, tagged with the call it came from.
if (resultImages.length) {
parts.push({ type: OPENAI_BLOCK.TEXT, text: `[Image from tool result ${block.tool_use_id}]` });
parts.push(...resultImages);
}
break;
}
}
}

View File

@@ -5,6 +5,7 @@
* - params.system: STRING at top level (Anthropic-style; system messages NOT allowed in messages[])
* - params.messages[*].role ∈ {"user","assistant","tool"}
* - params.messages[*].content: Array of content blocks (NEVER a string)
* - image_url / image source → {type:"image", image:"data:...;base64,...", mimeType}
* - tool_use blocks (assistant): {type:"tool-call", toolCallId, toolName, input}
* - tool_result blocks (role=user): {type:"tool-result", toolCallId, toolName, output}
* - tools[*]: Anthropic plain {name, description, input_schema}
@@ -12,10 +13,9 @@
import { register } from "../index.js";
import { FORMATS } from "../formats.js";
import { randomUUID } from "crypto";
import { ROLE, OPENAI_BLOCK } from "../schema/index.js";
import { DEFAULT_IMAGE_MIME } from "../schema/index.js";
import { parseDataUri } from "../concerns/image.js";
import { ROLE, OPENAI_BLOCK, CLAUDE_BLOCK } from "../schema/index.js";
import { DEFAULT_MAX_TOKENS } from "../../config/runtimeConfig.js";
import { parseDataUri, encodeDataUri } from "../concerns/image.js";
function flattenText(content) {
if (content == null) return "";
@@ -32,6 +32,58 @@ function flattenText(content) {
return String(content);
}
function toNativeImageBlock(part) {
if (!part || typeof part !== "object") return null;
if (part.type === OPENAI_BLOCK.IMAGE_URL) {
const url = typeof part.image_url === "string" ? part.image_url : part.image_url?.url;
if (!url) return null;
const parsed = parseDataUri(url);
if (parsed) {
return {
type: OPENAI_BLOCK.IMAGE,
image: encodeDataUri(parsed.mimeType, parsed.base64),
mimeType: parsed.mimeType,
};
}
if (typeof url === "string" && (url.startsWith("http://") || url.startsWith("https://"))) {
return {
type: OPENAI_BLOCK.IMAGE,
image: url,
};
}
return null;
}
if (part.type === OPENAI_BLOCK.IMAGE || part.type === CLAUDE_BLOCK.IMAGE) {
if (typeof part.image === "string" && part.image.startsWith("data:")) {
const parsed = parseDataUri(part.image);
return {
type: OPENAI_BLOCK.IMAGE,
image: part.image,
mimeType: part.mimeType || parsed?.mimeType || "image/png",
};
}
if (typeof part.image === "string" && (part.image.startsWith("http://") || part.image.startsWith("https://"))) {
return {
type: OPENAI_BLOCK.IMAGE,
image: part.image,
};
}
const source = part.source;
if (source?.type === "base64" && typeof source.data === "string") {
const mime = source.media_type || "image/png";
return {
type: OPENAI_BLOCK.IMAGE,
image: encodeDataUri(mime, source.data),
mimeType: mime,
};
}
}
return null;
}
function toContentBlocks(content) {
if (content == null) return [{ type: OPENAI_BLOCK.TEXT, text: "" }];
if (typeof content === "string")
@@ -44,30 +96,12 @@ function toContentBlocks(content) {
} else if (part && typeof part === "object") {
if (part.type === OPENAI_BLOCK.TEXT && typeof part.text === "string") {
blocks.push({ type: OPENAI_BLOCK.TEXT, text: part.text });
} else if (
part.type === OPENAI_BLOCK.IMAGE_URL ||
part.type === OPENAI_BLOCK.IMAGE
) {
// CommandCode `/alpha/generate` accepts {type:"image", image:"<data URI | url>"} —
// same shape the official command-code CLI sends (verified from CLI source).
const src = part.source;
let raw = part.image_url?.url || src?.data || src?.url || "";
let parsed = parseDataUri(raw);
if (!parsed && src?.type === "base64" && src?.data) {
// Claude-style base64 source without a data-URI prefix → wrap it.
raw = `data:${src.media_type || DEFAULT_IMAGE_MIME};base64,${src.data}`;
parsed = parseDataUri(raw);
} else {
const image = toNativeImageBlock(part);
if (image) blocks.push(image);
else if (typeof part.text === "string") {
blocks.push({ type: OPENAI_BLOCK.TEXT, text: part.text });
}
if (parsed) {
blocks.push({
type: "image",
image: `data:${parsed.mimeType};base64,${parsed.base64}`,
});
} else if (raw) {
blocks.push({ type: "image", image: raw });
}
} else if (typeof part.text === "string") {
blocks.push({ type: OPENAI_BLOCK.TEXT, text: part.text });
}
}
}
@@ -119,6 +153,10 @@ function convertMessages(messages = []) {
if (role === ROLE.ASSISTANT) {
const blocks = [];
const rc = m.reasoning_content || m.thought || m.reasoning;
if (rc || (Array.isArray(m.tool_calls) && m.tool_calls.length > 0)) {
blocks.push({ type: "reasoning", text: rc || " " });
}
const text = flattenText(m.content);
if (text) blocks.push({ type: OPENAI_BLOCK.TEXT, text });
if (Array.isArray(m.tool_calls)) {

View File

@@ -129,7 +129,7 @@ function openaiToGeminiBase(model, body, stream, signature = DEFAULT_THINKING_AG
if (tc.type !== OPENAI_BLOCK.FUNCTION) continue;
const args = tryParseJSON(tc.function?.arguments || "{}");
const cachedSig = tc.id ? getGeminiThoughtSignatureSync(tc.id, sessionId) : null;
const cachedSig = tc.id ? getGeminiThoughtSignatureSync(tc.id, sessionId, model) : null;
// First call gets cached signature or fallback; sibling calls remain unsigned if no cached sig
const callSig = cachedSig || (!firstFunctionCallSeen ? signature : undefined);
firstFunctionCallSeen = true;
@@ -341,7 +341,7 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu
if (block.type === CLAUDE_BLOCK.TEXT) {
parts.push({ text: block.text });
} else if (block.type === CLAUDE_BLOCK.TOOL_USE) {
const cachedSig = block.id ? getGeminiThoughtSignatureSync(block.id, credentials?._clientSessionId) : null;
const cachedSig = block.id ? getGeminiThoughtSignatureSync(block.id, credentials?._clientSessionId, model) : null;
const callSig = cachedSig || (!firstToolUseSeen ? signature : undefined);
firstToolUseSeen = true;

View File

@@ -23,6 +23,7 @@ import { ROLE, OPENAI_BLOCK, CLAUDE_BLOCK } from "../schema/index.js";
import {
canonicalizeKiroConversation,
normalizeKiroToolSpecs,
kiroEmptyUserContent,
} from "../concerns/kiroConversation.js";
/**
@@ -51,7 +52,8 @@ function convertMessages(messages, model) {
const flushPending = () => {
if (currentRole === "user") {
const content = pendingUserContent.join("\n\n").trim() || "continue";
const content = pendingUserContent.join("\n\n").trim()
|| kiroEmptyUserContent(pendingToolResults.length > 0);
const userMsg = {
userInputMessage: {
content: content,
@@ -434,6 +436,13 @@ export function openaiToKiroRequest(model, body, stream, credentials) {
enumerable: false
});
// Kiro tool specs get sanitized names (`mcp__a__b` → `mcp_a_b`); keep the
// reverse map so tool calls stream back under the client's own names.
const restoredToolNames = new Map();
for (const [original, sanitized] of nameMap) {
if (original !== sanitized) restoredToolNames.set(sanitized, original);
}
if (restoredToolNames.size) payload._toolNameMap = restoredToolNames;
return payload;
}