import type { CommitConfig, GeneratedCommitMessage } from "../types.ts"; import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; import { complete, type UserMessage } from "@earendil-works/pi-ai/compat"; import { formatSafeErrorDetail, parseCommitCompletionResponse } from "./parse-response.ts"; import { createRequestAbortContext, type RequestAbortCause } from "./request-abort.ts"; type SelectedModel = NonNullable; type ModelAuthResult = Awaited< ReturnType >; /** 根据配置选择侧通道模型,但不修改当前会话模型。 */ function resolveModel(config: CommitConfig, ctx: ExtensionCommandContext): SelectedModel { if (config.provider !== null && config.model !== null) { const configuredModel = ctx.modelRegistry.find(config.provider, config.model); if (!configuredModel) { throw new Error( `Configured model ${config.provider}/${config.model} was not found in the Pi model registry. Check provider and model in ai-git-commit.json.`, ); } return configuredModel; } if (!ctx.model) { throw new Error( "No model is available in the current session. Select a model in Pi or configure provider and model in ai-git-commit.json.", ); } return ctx.model; } /** 根据模型上限约束本次侧通道输出 token 数。 */ function selectMaxTokens(configured: number, modelMaximum?: number): number { if (!modelMaximum || !Number.isFinite(modelMaximum) || modelMaximum <= 0) { return configured; } return Math.max(1, Math.min(configured, Math.floor(modelMaximum))); } /** 获取模型认证信息,并为注册表异常补充可执行诊断。 */ async function getModelAuth( model: SelectedModel, modelLabel: string, ctx: ExtensionCommandContext, ): Promise { try { return await ctx.modelRegistry.getApiKeyAndHeaders(model); } catch (error) { throw new Error( `Failed to resolve credentials for model ${modelLabel}: ${formatSafeErrorDetail(error)} Check provider, model, and credentials in Pi.`, ); } } /** 用户主动取消生成时抛出的专用错误,供上层降级为普通提示而非错误。 */ export class GenerationCancelledError extends Error { /** 使用固定的取消文案并标记错误名称。 */ constructor() { super("Commit message generation was cancelled."); this.name = "GenerationCancelledError"; } } /** 将已记录的中止来源转换为准确且可执行的用户错误。 */ function createGenerationAbortError( cause: RequestAbortCause | undefined, timeoutMs: number | null, ): Error | undefined { if (cause === "timeout") { return new Error( `Commit message generation timed out after ${timeoutMs} ms. Increase timeoutMs in ai-git-commit.json, or set it to null to disable the timeout.`, ); } if (cause === "external") { return new GenerationCancelledError(); } return undefined; } /** 使用选定模型发起独立的侧通道调用并生成提交信息。 */ export async function generateCommitMessage( config: CommitConfig, systemPrompt: string, prompt: string, ctx: ExtensionCommandContext, signal?: AbortSignal, ): Promise { const model = resolveModel(config, ctx); const modelLabel = `${model.provider}/${model.id}`; const maxOutputTokens = selectMaxTokens(config.maxOutputTokens, model.maxTokens); const auth = await getModelAuth(model, modelLabel, ctx); if (!auth.ok) { throw new Error( `Unable to authenticate or configure model ${modelLabel}: ${formatSafeErrorDetail(auth.error)} Check provider, model, and credentials in Pi.`, ); } const userMessage: UserMessage = { role: "user", content: [{ type: "text", text: prompt }], timestamp: Date.now(), }; const abortContext = createRequestAbortContext(config.timeoutMs, signal); let response: Awaited>; try { response = await complete( model, { systemPrompt, messages: [userMessage], }, { apiKey: auth.apiKey, headers: auth.headers, env: auth.env, signal: abortContext.signal, maxTokens: maxOutputTokens, cacheRetention: "none", }, ); } catch (error) { const abortError = createGenerationAbortError(abortContext.getCause(), config.timeoutMs); if (abortError) { throw abortError; } throw new Error( `Model request for ${modelLabel} failed before a response was returned: ${formatSafeErrorDetail(error)} Check provider, model, credentials, base URL, and network connection.`, ); } finally { abortContext.dispose(); } const abortError = createGenerationAbortError(abortContext.getCause(), config.timeoutMs); if (abortError) { throw abortError; } return { message: parseCommitCompletionResponse(response, { modelLabel, maxOutputTokens, }), modelLabel, }; }