export interface CommitCompletionResponse { content: unknown; stopReason: string; errorMessage?: string; } export interface CommitResponseContext { modelLabel: string; maxOutputTokens: number; } const MAX_ERROR_DETAIL_LENGTH = 600; /** 将未知错误转换为经过脱敏和截断的安全诊断文本。 */ export function formatSafeErrorDetail(error: unknown): string { const raw = error instanceof Error ? error.message : String(error); const redacted = raw .replace(/\bauthorization\s*[:=]\s*(?:bearer\s+)?[^\s,;]+/giu, "Authorization: [REDACTED]") .replace(/\bbearer\s+[^\s,;]+/giu, "Bearer [REDACTED]") .replace(/\b(api[-_ ]?key|access[-_ ]?token)\s*[:=]\s*[^\s,;]+/giu, "$1: [REDACTED]") .replace(/([?&](?:key|token|api_key)=)[^&\s]+/giu, "$1[REDACTED]"); if (redacted.length <= MAX_ERROR_DETAIL_LENGTH) { return redacted; } return `${redacted.slice(0, MAX_ERROR_DETAIL_LENGTH)}…`; } /** 汇总模型响应中的内容块类型,但不暴露具体内容。 */ function describeContentTypes(content: unknown): string { if (!Array.isArray(content) || content.length === 0) { return "none"; } const types = content.map((part) => { if ( typeof part === "object" && part !== null && "type" in part && typeof part.type === "string" ) { return part.type; } return typeof part; }); return [...new Set(types)].join(", "); } /** 从模型响应内容中提取所有文本块。 */ function extractTextContent(content: unknown): string { if (!Array.isArray(content)) { return ""; } return content .filter( (part): part is { type: "text"; text: string } => typeof part === "object" && part !== null && "type" in part && part.type === "text" && "text" in part && typeof part.text === "string", ) .map((part) => part.text) .join("\n") .trim(); } /** 清理常见包装,同时保留合法的多行提交正文。 */ function normalizeCommitMessage(raw: string): string { let message = raw.replaceAll("\r\n", "\n").trim(); const fenced = message.match(/^```(?:[\w-]+)?\s*\n([\s\S]*?)\n```$/); if (fenced?.[1] !== undefined) { message = fenced[1].trim(); } message = message.replace( /^(?:commit message|git commit message|提交信息|提交消息)\s*[::]\s*/i, "", ); return message; } /** 根据停止原因和内容类型提取消息,失败时返回可执行的配置诊断。 */ export function parseCommitCompletionResponse( response: CommitCompletionResponse, context: CommitResponseContext, ): string { const contentTypes = describeContentTypes(response.content); const responseSummary = `stop reason: ${response.stopReason}; content types: ${contentTypes}`; if (response.stopReason === "aborted") { throw new Error("Commit message generation was cancelled."); } if (response.stopReason === "error") { const detail = response.errorMessage ? formatSafeErrorDetail(response.errorMessage) : "The provider returned an error without details."; throw new Error( `Model request failed for ${context.modelLabel}: ${detail} Check the provider, model ID, credentials, base URL, and network connection.`, ); } if (response.stopReason === "length") { throw new Error( `Model ${context.modelLabel} reached the output limit of ${context.maxOutputTokens} tokens before completing a commit message (${responseSummary}). Increase maxOutputTokens in ai-git-commit.json, up to 4096, or use a model with a smaller reasoning budget.`, ); } if (response.stopReason === "toolUse") { throw new Error( `Model ${context.modelLabel} attempted a tool call instead of returning a commit message (${responseSummary}). Check provider/model compatibility and use a text-generation model.`, ); } const raw = extractTextContent(response.content); if (!raw) { throw new Error( `Model ${context.modelLabel} returned no text content (${responseSummary}). If the response is thinking-only, increase maxOutputTokens in ai-git-commit.json; otherwise check provider/model compatibility.`, ); } const message = normalizeCommitMessage(raw); if (!message) { throw new Error( `Model ${context.modelLabel} returned text, but no usable commit message remained after removing common wrappers (${responseSummary}). Adjust prompt or choose another model.`, ); } if (message.includes("\0")) { throw new Error( `Model ${context.modelLabel} returned a commit message containing an invalid NUL character. Adjust prompt or choose another model.`, ); } return message; }