merge: integrate origin/master (v0.5.50) into gitea/new_feature

- Resolve conflicts in chatCore handlers: keep apiKey/streamErrorPatterns
  from the details-filters feature, adopt origin's stripContinuityFields,
  customToolNames, cache-inclusive usage accounting, and Responses-API
  SSE→JSON conversion
- Adopt origin's provider usage handlers (codebuddy-intl, qoder creds)
  and modality detection (audio/video inputs)
- Keep requestDetails apiKey column (schema v2) + masked key persistence

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
2026-08-06 09:54:31 +07:00
180 changed files with 8622 additions and 6233 deletions

View File

@@ -3,6 +3,7 @@
// never hardcoded per-model here. See .docs/thinking/plan.md MATRIX VI-A.
import { getCapabilitiesForModel } from "../../providers/capabilities.js";
import { getThinkingLevels } from "../../providers/thinkingLevels.js";
import { PROVIDERS } from "../../providers/index.js";
import { LEVEL_TO_BUDGET, budgetToLevel, effortToBudget, effortToThinkingLevel } from "./thinking.js";
@@ -37,6 +38,7 @@ export function parseSuffix(model) {
const raw = m[2].trim().toLowerCase();
if (raw === "none" || raw === "off") return { cleanModel, override: { mode: "none" } };
if (raw === "auto") return { cleanModel, override: { mode: "auto" } };
if (raw === "ultra") return { cleanModel, override: { mode: "level", level: raw } };
if (/^\d+$/.test(raw)) return { cleanModel, override: { mode: "budget", budget: Number(raw) } };
if (LEVEL_TO_BUDGET[raw] !== undefined) return { cleanModel, override: { mode: "level", level: raw } };
return { cleanModel, override: null };
@@ -134,6 +136,13 @@ function toLevel(cfg) {
return null;
}
function normalizeOpenAILevel(level, supportedLevels) {
if (level !== "max" && level !== "ultra") return level;
if (supportedLevels?.includes(level)) return level;
if (level === "ultra" && supportedLevels?.includes("max")) return "max";
return "xhigh";
}
function toGeminiThinkingLevel(cfg) {
const raw = cfg.mode === "auto" ? "high" : (toLevel(cfg) || "high");
return effortToThinkingLevel(raw);
@@ -213,7 +222,7 @@ function stripAll(body) {
}
// Apply unified thinking config to body in the resolved provider-native format.
function applyFormat(fmt, body, cfg, caps) {
function applyFormat(fmt, body, cfg, caps, supportedLevels) {
const none = cfg.mode === "none";
const canDisable = caps.thinkingCanDisable !== false;
// Model cannot disable thinking → clamp "none" to minimal effort instead.
@@ -223,8 +232,7 @@ function applyFormat(fmt, body, cfg, caps) {
case "openai": {
if (none && canDisable) { body.reasoning_effort = "none"; break; }
const level = toLevel(eff);
// OpenAI reasoning_effort enum caps at "xhigh" (no "max"); clamp Claude Code's "max".
if (level) body.reasoning_effort = level === "max" ? "xhigh" : level;
if (level) body.reasoning_effort = normalizeOpenAILevel(level, supportedLevels);
break;
}
case "claude-adaptive": {
@@ -302,6 +310,15 @@ function applyFormat(fmt, body, cfg, caps) {
if (level) body.reasoning_effort = level === "xhigh" || level === "max" ? "high" : level;
break;
}
case "tokenrouter": {
// TokenRouter's reasoning_effort enum is low/medium/high/xhigh/max — it rejects
// "none"/"auto" with a 400 and supports "max" natively (no clamp like openai).
// "none" → omit the field so the upstream default applies; pass levels through.
if (none || eff.mode === "auto") break;
const level = toLevel(eff);
if (level) body.reasoning_effort = level;
break;
}
case "kiro":
// Kiro thinking handled via system-tag injection in openai-to-kiro.js; no body field here.
break;
@@ -329,7 +346,8 @@ export function applyThinking(targetFormat, model, body, provider = null, intent
if (!cfg) return body;
const fmt = resolveFormat(targetFormat, cleanModel, provider);
const supportedLevels = getThinkingLevels(provider, cleanModel);
stripAll(body);
applyFormat(fmt, body, cfg, caps);
applyFormat(fmt, body, cfg, caps, supportedLevels);
return body;
}

View File

@@ -16,7 +16,9 @@ export function hasValidContent(msg) {
return msg.content.some(block =>
(block.type === CLAUDE_BLOCK.TEXT && block.text?.trim()) ||
block.type === CLAUDE_BLOCK.TOOL_USE ||
block.type === CLAUDE_BLOCK.TOOL_RESULT
block.type === CLAUDE_BLOCK.TOOL_RESULT ||
block.type === CLAUDE_BLOCK.IMAGE ||
block.type === CLAUDE_BLOCK.DOCUMENT
);
}
return false;

View File

@@ -7,7 +7,13 @@ import { OPENAI_BLOCK } from "../schema/index.js";
export const UNSUPPORTED_SCHEMA_CONSTRAINTS = [
// Basic constraints (not supported by Gemini API)
"minLength", "maxLength", "exclusiveMinimum", "exclusiveMaximum",
"minItems", "maxItems", "format",
"minItems", "maxItems", "format", "multipleOf",
// Array keywords the Gemini schema proto has no field for. Agent tool
// schemas set these routinely, and one occurrence rejects the whole request
// with "Unknown name ...: Cannot find field".
"uniqueItems", "contains",
// 2020-12 keywords with no Gemini equivalent
"unevaluatedProperties", "unevaluatedItems", "contentSchema",
// Claude rejects these in VALIDATED mode
"default", "examples",
// JSON Schema meta keywords

View File

@@ -258,8 +258,10 @@ export function initState(sourceFormat) {
funcArgsBuf: {},
funcNames: {},
funcCallIds: {},
funcItemAdded: {},
funcArgsDone: {},
funcItemDone: {},
customToolNames: new Set(),
completedSent: false
};
}

View File

@@ -32,6 +32,8 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
let pendingToolResults = [];
let pendingReasoning = "";
let pendingReasoningEncrypted = "";
const additionalTools = [];
const customToolNames = new Set();
const inputItems = normalizeResponsesInput(body.input);
if (!inputItems) return body;
@@ -96,7 +98,7 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
}
result.messages.push(msg);
}
else if (itemType === RESPONSES_ITEM.FUNCTION_CALL) {
else if (itemType === RESPONSES_ITEM.FUNCTION_CALL || itemType === RESPONSES_ITEM.CUSTOM_TOOL_CALL) {
// Start or append to assistant message with tool_calls
if (!currentAssistantMsg) {
currentAssistantMsg = {
@@ -108,16 +110,20 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
}
// Skip items with empty/missing name — Codex/OpenAI reject nameless tool calls (#444)
if (!item.name || typeof item.name !== "string" || item.name.trim() === "") continue;
if (itemType === RESPONSES_ITEM.CUSTOM_TOOL_CALL) customToolNames.add(item.name);
const toolInput = itemType === RESPONSES_ITEM.CUSTOM_TOOL_CALL
? { input: typeof item.input === "string" ? item.input : JSON.stringify(item.input ?? "") }
: item.arguments;
currentAssistantMsg.tool_calls.push({
id: item.call_id,
type: OPENAI_BLOCK.FUNCTION,
function: {
name: item.name,
arguments: item.arguments
arguments: typeof toolInput === "string" ? toolInput : JSON.stringify(toolInput ?? {})
}
});
}
else if (itemType === RESPONSES_ITEM.FUNCTION_CALL_OUTPUT) {
else if (itemType === RESPONSES_ITEM.FUNCTION_CALL_OUTPUT || itemType === RESPONSES_ITEM.CUSTOM_TOOL_CALL_OUTPUT) {
// Flush assistant message first if exists
if (currentAssistantMsg) {
result.messages.push(currentAssistantMsg);
@@ -137,6 +143,9 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
content: typeof item.output === "string" ? item.output : JSON.stringify(item.output)
});
}
else if (itemType === RESPONSES_ITEM.ADDITIONAL_TOOLS) {
if (Array.isArray(item.tools)) additionalTools.push(...item.tools);
}
else if (itemType === RESPONSES_ITEM.REASONING) {
// Buffer reasoning text; attached to next assistant message/function_call.
// Also stash encrypted_content so a later openai→responses hop can restore
@@ -166,15 +175,45 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
// explicit `name` field and cannot be represented as Chat Completions function declarations.
// Filter them out to avoid sending nameless functionDeclarations to downstream providers
// such as Gemini, which strictly validates function names.
if (body.tools && Array.isArray(body.tools)) {
result.tools = body.tools
const responseTools = [
...(Array.isArray(body.tools) ? body.tools : []),
...additionalTools,
];
if (responseTools.length > 0) {
result.tools = responseTools
.map(tool => {
// Already in Chat Completions format: { type: "function", function: { name, ... } }
if (tool.function) return tool;
// Responses API function tool: { type: "function", name, description, parameters }
// Only convert when a non-empty name is present; skip hosted tools without one.
// Responses API function/custom tool: { type, name, description, parameters|format }.
// Chat Completions has no freeform custom-tool declaration, so expose custom
// tools as functions with one raw `input` string while retaining their names
// in translator-only metadata for the response conversion.
const name = tool.name;
if (!name || typeof name !== "string" || name.trim() === "") return null;
if (tool.type === "custom") {
customToolNames.add(name);
const formatHint = [tool.format?.syntax, tool.format?.definition].filter(Boolean).join("\n");
return {
type: OPENAI_BLOCK.FUNCTION,
function: {
name,
description: [String(tool.description || ""), formatHint].filter(Boolean).join("\n\n"),
parameters: {
type: "object",
properties: {
input: {
type: "string",
description: "Raw freeform input for this custom tool"
}
},
required: ["input"],
additionalProperties: false
}
}
};
}
// Responses API function tool: { type: "function", name, description, parameters }
// Only convert when a non-empty name is present; skip hosted tools without one.
return {
type: OPENAI_BLOCK.FUNCTION,
function: {
@@ -187,6 +226,7 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
})
.filter(Boolean);
}
if (customToolNames.size > 0) result._customToolNames = [...customToolNames];
// Cleanup Responses API specific fields
// Map Responses-only max_output_tokens to Chat max_tokens (avoid leaking unknown field upstream)

View File

@@ -258,24 +258,43 @@ function closeMessage(state, emit, idx) {
}
}
function isCustomTool(state, name) {
return !!name && state.customToolNames?.has(name);
}
function extractCustomToolInput(argumentsText) {
if (typeof argumentsText !== "string") return "";
try {
const parsed = JSON.parse(argumentsText);
if (parsed && typeof parsed === "object" && typeof parsed.input === "string") return parsed.input;
} catch { /* incomplete or raw freeform input */ }
return argumentsText;
}
function emitToolCall(state, emit, tc) {
const tcIdx = tc.index ?? 0;
const newCallId = tc.id;
const funcName = tc.function?.name;
if (funcName) state.funcNames[tcIdx] = funcName;
if (newCallId) state.funcCallIds[tcIdx] = newCallId;
// Some compatible providers split the call id and function name across
// chunks. Wait for both before deciding whether this is a custom tool;
// otherwise an `exec` call can be irreversibly announced as function_call.
const callId = state.funcCallIds[tcIdx];
if (!state.funcItemAdded[tcIdx] && callId && state.funcNames[tcIdx]) {
state.funcItemAdded[tcIdx] = true;
const custom = isCustomTool(state, state.funcNames[tcIdx]);
if (!state.funcCallIds[tcIdx] && newCallId) {
state.funcCallIds[tcIdx] = newCallId;
emit("response.output_item.added", {
type: "response.output_item.added",
output_index: tcIdx,
item: {
id: `fc_${newCallId}`,
type: RESPONSES_ITEM.FUNCTION_CALL,
arguments: "",
call_id: newCallId,
id: `${custom ? "ctc" : "fc"}_${callId}`,
type: custom ? RESPONSES_ITEM.CUSTOM_TOOL_CALL : RESPONSES_ITEM.FUNCTION_CALL,
...(custom ? { input: "" } : { arguments: "" }),
call_id: callId,
name: state.funcNames[tcIdx] || ""
}
});
@@ -285,7 +304,7 @@ function emitToolCall(state, emit, tc) {
if (tc.function?.arguments) {
const refCallId = state.funcCallIds[tcIdx] || newCallId;
if (refCallId) {
if (state.funcItemAdded[tcIdx] && refCallId && !isCustomTool(state, state.funcNames[tcIdx])) {
emit("response.function_call_arguments.delta", {
type: "response.function_call_arguments.delta",
item_id: `fc_${refCallId}`,
@@ -293,6 +312,9 @@ function emitToolCall(state, emit, tc) {
delta: tc.function.arguments
});
}
// Custom input is emitted once at close, after the Chat JSON wrapper can be
// parsed and unwrapped. Streaming the raw JSON fragments would expose
// {"input":"..."} instead of the freeform program Codex expects.
state.funcArgsBuf[tcIdx] += tc.function.arguments;
}
}
@@ -301,21 +323,38 @@ function closeToolCall(state, emit, idx) {
const callId = state.funcCallIds[idx];
if (callId && !state.funcItemDone[idx]) {
const args = state.funcArgsBuf[idx] || "{}";
emit("response.function_call_arguments.done", {
type: "response.function_call_arguments.done",
item_id: `fc_${callId}`,
output_index: parseInt(idx),
arguments: args
});
const custom = isCustomTool(state, state.funcNames[idx]);
if (custom) {
const input = extractCustomToolInput(args);
emit("response.custom_tool_call_input.delta", {
type: "response.custom_tool_call_input.delta",
item_id: `ctc_${callId}`,
output_index: parseInt(idx),
delta: input
});
emit("response.custom_tool_call_input.done", {
type: "response.custom_tool_call_input.done",
item_id: `ctc_${callId}`,
output_index: parseInt(idx),
input
});
} else {
emit("response.function_call_arguments.done", {
type: "response.function_call_arguments.done",
item_id: `fc_${callId}`,
output_index: parseInt(idx),
arguments: args
});
}
emit("response.output_item.done", {
type: "response.output_item.done",
output_index: parseInt(idx),
item: {
id: `fc_${callId}`,
type: RESPONSES_ITEM.FUNCTION_CALL,
arguments: args,
id: `${custom ? "ctc" : "fc"}_${callId}`,
type: custom ? RESPONSES_ITEM.CUSTOM_TOOL_CALL : RESPONSES_ITEM.FUNCTION_CALL,
...(custom ? { input: extractCustomToolInput(args) } : { arguments: args }),
call_id: callId,
name: state.funcNames[idx] || ""
}

View File

@@ -27,6 +27,9 @@ export const RESPONSES_ITEM = {
MESSAGE: "message",
FUNCTION_CALL: "function_call",
FUNCTION_CALL_OUTPUT: "function_call_output",
CUSTOM_TOOL_CALL: "custom_tool_call",
CUSTOM_TOOL_CALL_OUTPUT: "custom_tool_call_output",
ADDITIONAL_TOOLS: "additional_tools",
REASONING: "reasoning",
OUTPUT_TEXT: "output_text",
INPUT_TEXT: "input_text",