From 520407224001e4e61969bc21e150812f3211ad65 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Thu, 7 May 2026 10:31:40 -0300 Subject: [PATCH 1/5] Return `GeneratedContent` with request metadata from LLM router - Introduce `GeneratedContent`, `LlmRequestMetadata`, `TokenUsage`, and `ProviderGeneratedContent` types to expose request duration and token usage. - Add `withRequestMetadata` helper to wrap provider responses with timing information. - Update `generateContent`, `generateCommitMessage`, and `refineCommitMessage` to return `GeneratedContent` instead of plain strings. --- src/domain/llm/router.ts | 54 ++++++++++++++++++++++++++++++++++------ 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/src/domain/llm/router.ts b/src/domain/llm/router.ts index 231b253..865da55 100644 --- a/src/domain/llm/router.ts +++ b/src/domain/llm/router.ts @@ -1,4 +1,12 @@ -export { type GenerateContentParams, generateCommitMessage, refineCommitMessage }; +export { + type GenerateContentParams, + type GeneratedContent, + type LlmRequestMetadata, + type ProviderGeneratedContent, + type TokenUsage, + generateCommitMessage, + refineCommitMessage +}; import { Future } from "@/libs/future"; import { type ProviderConfig, type CommitConvention } from "@/domain/config/config"; @@ -13,14 +21,46 @@ type GenerateContentParams = { readonly systemInstruction?: string; }; -const generateContent = (config: ProviderConfig, params: GenerateContentParams): Future => { +type TokenUsage = { + readonly input: Maybe; + readonly output: Maybe; + readonly total: Maybe; +}; + +type LlmRequestMetadata = { + readonly durationMs: number; + readonly tokens: Maybe; +}; + +type GeneratedContent = { + readonly text: string; + readonly metadata: LlmRequestMetadata; +}; + +type ProviderGeneratedContent = { + readonly text: string; + readonly tokens: Maybe; +}; + +const withRequestMetadata = (f: Future): Future => { + const startedAt = Date.now(); + return f.map(({ text, tokens }) => ({ + text, + metadata: { + durationMs: Date.now() - startedAt, + tokens + } + })); +}; + +const generateContent = (config: ProviderConfig, params: GenerateContentParams): Future => { switch (config.provider) { case "gemini": - return generateContentWithGemini(config, params); + return withRequestMetadata(generateContentWithGemini(config, params)); case "openai": - return generateContentWithOpenAI(config, params); + return withRequestMetadata(generateContentWithOpenAI(config, params)); case "anthropic": - return generateContentWithAnthropic(config, params); + return withRequestMetadata(generateContentWithAnthropic(config, params)); } }; @@ -29,7 +69,7 @@ const generateCommitMessage = ( diff: string, convention: CommitConvention, customTemplate: Maybe = Nothing() -): Future => generateContent(config, { prompt: getPrompt(diff, convention, customTemplate) }); +): Future => generateContent(config, { prompt: getPrompt(diff, convention, customTemplate) }); -const refineCommitMessage = (config: ProviderConfig, currentMessage: string, adjustment: string, diff: string): Future => +const refineCommitMessage = (config: ProviderConfig, currentMessage: string, adjustment: string, diff: string): Future => generateContent(config, getRefinePrompt({ diff, currentMessage, adjustment })); From 7417210a8bb3bd2e286fc51c41933b6598b8a3b3 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Thu, 7 May 2026 18:07:10 -0300 Subject: [PATCH 2/5] Surface LLM request metadata in commit and push notes - Thread `GeneratedContent` through the commit/refine/interact flow so model, duration, and token usage are preserved end-to-end. - Extend `LlmRequestMetadata` with `ModelRequestMetadata` capturing provider, model, and effort. - Capture token usage from Anthropic, Gemini, and OpenAI provider responses and return `ProviderGeneratedContent`. - Add `renderCommitNote` and extend `renderPushNote` to show model, request duration, and token counts. - Introduce `withTransientRetry` helper in `src/infra/llm/retry.ts` and apply it to Anthropic API key and setup-token calls. - Configure Anthropic client with `maxRetries: 3` and a 120s timeout, and wrap errors with `cause` for retry classification. --- src/cli/commit.ts | 61 +++++++++++++++++---------------- src/domain/llm/router.ts | 27 ++++++++++++--- src/infra/llm/anthropic.ts | 69 +++++++++++++++++++++++++++----------- src/infra/llm/gemini.ts | 50 +++++++++++++++++++-------- src/infra/llm/openai.ts | 41 +++++++++++++++++----- src/infra/llm/retry.ts | 66 ++++++++++++++++++++++++++++++++++++ src/infra/ui/push-note.ts | 46 +++++++++++++++++++++++-- 7 files changed, 284 insertions(+), 76 deletions(-) create mode 100644 src/infra/llm/retry.ts diff --git a/src/cli/commit.ts b/src/cli/commit.ts index b9c0812..1c37e9d 100644 --- a/src/cli/commit.ts +++ b/src/cli/commit.ts @@ -9,10 +9,10 @@ import { loadConfig } from "@/infra/storage/config"; import { Setup } from "@/cli/setup"; import { type CommitConvention, type Config, type ProviderConfig } from "@/domain/config/config"; import { resolveProvider } from "@/domain/llm/auth-resolver"; -import { generateCommitMessage, refineCommitMessage } from "@/domain/llm/router"; +import { generateCommitMessage, refineCommitMessage, type GeneratedContent, type LlmRequestMetadata } from "@/domain/llm/router"; import { Nothing, type Maybe, Just } from "@/libs/maybe"; import { loading } from "@/infra/ui/spinner"; -import { renderPushNote } from "@/infra/ui/push-note"; +import { renderCommitNote, renderPushNote } from "@/infra/ui/push-note"; import color from "picocolors"; @@ -56,11 +56,11 @@ class Commit { return repo.getStagedDiff(); } - generate(diff: string, convention: CommitConvention, template: Maybe = Nothing()): Future { + generate(diff: string, convention: CommitConvention, template: Maybe = Nothing()): Future { return loading("Generating commit message...", "Message generated!", generateCommitMessage(this.providerConfig, diff, convention, template)); } - refine(message: string, adjustment: string, diff: string): Future { + refine(message: string, adjustment: string, diff: string): Future { return loading("Refining...", "Refined!", refineCommitMessage(this.providerConfig, message, adjustment, diff)); } @@ -68,7 +68,7 @@ class Commit { return repo.performCommit(message); } - push(branch?: string, publish = false, forceWithLease = false): Future { + push(request: Maybe, branch?: string, publish = false, forceWithLease = false): Future { const startMsg = forceWithLease ? "Force pushing with lease..." : publish ? `Publishing '${branch}'...` @@ -95,21 +95,21 @@ class Commit { baseBranch: repo.findBaseBranch(), remoteUrl: repo.findTrackingRemoteUrl(), pr: pr.getOpenPullRequest() - }).map((parts) => renderPushNote({ ...parts, range: result.range })) + }).map((parts) => renderPushNote({ ...parts, range: result.range, request })) ); } - interact(diff: string, message: string): Future { - return this.promptAction(message).chain((action) => { + interact(diff: string, generated: GeneratedContent): Future { + return this.promptAction(generated.text).chain((action) => { switch (action) { case "commit": - return this.handleCommit(message); + return this.handleCommit(generated); case "commit_push": - return this.handleCommitAndPush(message); + return this.handleCommitAndPush(generated); case "regenerate": return this.generate(diff, this.config.commit_convention, this.config.custom_template).chain((msg) => this.interact(diff, msg)); case "adjust": - return this.handleAdjust(diff, message); + return this.handleAdjust(diff, generated); case "cancel": return Future.resolve(undefined); } @@ -140,59 +140,62 @@ class Commit { }); } - private handleCommit(message: string): Future { - return this.commit(message).map((stats) => { - process.stdout.write(stats); - p.outro(color.green("Committed successfully!")); - }); + private handleCommit(generated: GeneratedContent): Future { + return this.commit(generated.text).chain((stats) => + repo.findCommitMetadata().map((commit) => { + process.stdout.write(stats); + renderCommitNote({ commit, request: Just(generated.metadata) }); + p.outro(color.green("Committed successfully!")); + }) + ); } - private handleCommitAndPush(message: string): Future { - return this.commit(message) + private handleCommitAndPush(generated: GeneratedContent): Future { + return this.commit(generated.text) .chain((stats) => { process.stdout.write(stats); - return this.pushAfterCommit(); + return this.pushAfterCommit(Just(generated.metadata)); }) .map(() => { p.outro(color.green("Done!")); }); } - private pushAfterCommit(): Future { + private pushAfterCommit(request: Maybe): Future { return repo .hasUpstream() .chain((exists) => exists ? - this.push().chainRej((err) => (isNonFastForwardError(err) ? this.promptForceWithLease() : Future.reject(err))) - : this.promptPublishBranch() + this.push(request).chainRej((err) => (isNonFastForwardError(err) ? this.promptForceWithLease(request) : Future.reject(err))) + : this.promptPublishBranch(request) ); } - private promptPublishBranch(): Future { + private promptPublishBranch(request: Maybe): Future { return repo.getCurrentBranch().chain((branch) => Future.attemptP(async () => { const publish = await p.confirm({ message: `Branch '${branch}' has no upstream. Publish to origin?` }); return !(p.isCancel(publish) || !publish); - }).chain((shouldPublish) => (shouldPublish ? this.push(branch, true) : Future.resolve(undefined))) + }).chain((shouldPublish) => (shouldPublish ? this.push(request, branch, true) : Future.resolve(undefined))) ); } - private promptForceWithLease(): Future { + private promptForceWithLease(request: Maybe): Future { return Future.attemptP(async () => { const force = await p.confirm({ message: "Push was rejected (branch is behind remote). Force push with lease?" }); return !(p.isCancel(force) || !force); - }).chain((shouldForce) => (shouldForce ? this.push(undefined, false, true) : Future.resolve(undefined))); + }).chain((shouldForce) => (shouldForce ? this.push(request, undefined, false, true) : Future.resolve(undefined))); } - private handleAdjust(diff: string, message: string): Future { + private handleAdjust(diff: string, generated: GeneratedContent): Future { return this.promptAdjustment().chain((maybeAdj) => maybeAdj instanceof Nothing ? - this.interact(diff, message) - : this.refine(message, maybeAdj.value, diff).chain((refined) => this.interact(diff, refined)) + this.interact(diff, generated) + : this.refine(generated.text, maybeAdj.value, diff).chain((refined) => this.interact(diff, refined)) ); } diff --git a/src/domain/llm/router.ts b/src/domain/llm/router.ts index 865da55..73a6d27 100644 --- a/src/domain/llm/router.ts +++ b/src/domain/llm/router.ts @@ -2,6 +2,7 @@ export { type GenerateContentParams, type GeneratedContent, type LlmRequestMetadata, + type ModelRequestMetadata, type ProviderGeneratedContent, type TokenUsage, generateCommitMessage, @@ -27,8 +28,15 @@ type TokenUsage = { readonly total: Maybe; }; +type ModelRequestMetadata = { + readonly provider: ProviderConfig["provider"]; + readonly model: string; + readonly effort: string; +}; + type LlmRequestMetadata = { readonly durationMs: number; + readonly model: ModelRequestMetadata; readonly tokens: Maybe; }; @@ -42,12 +50,23 @@ type ProviderGeneratedContent = { readonly tokens: Maybe; }; -const withRequestMetadata = (f: Future): Future => { +const modelRequestMetadata = (config: ProviderConfig): ModelRequestMetadata => { + switch (config.provider) { + case "openai": + return { provider: config.provider, model: config.model, effort: config.effort.maybe("provider default", (effort) => effort) }; + case "gemini": + case "anthropic": + return { provider: config.provider, model: config.model, effort: config.effort.maybe("medium", (effort) => effort) }; + } +}; + +const withRequestMetadata = (config: ProviderConfig, f: Future): Future => { const startedAt = Date.now(); return f.map(({ text, tokens }) => ({ text, metadata: { durationMs: Date.now() - startedAt, + model: modelRequestMetadata(config), tokens } })); @@ -56,11 +75,11 @@ const withRequestMetadata = (f: Future): Future const generateContent = (config: ProviderConfig, params: GenerateContentParams): Future => { switch (config.provider) { case "gemini": - return withRequestMetadata(generateContentWithGemini(config, params)); + return withRequestMetadata(config, generateContentWithGemini(config, params)); case "openai": - return withRequestMetadata(generateContentWithOpenAI(config, params)); + return withRequestMetadata(config, generateContentWithOpenAI(config, params)); case "anthropic": - return withRequestMetadata(generateContentWithAnthropic(config, params)); + return withRequestMetadata(config, generateContentWithAnthropic(config, params)); } }; diff --git a/src/infra/llm/anthropic.ts b/src/infra/llm/anthropic.ts index 82206dc..26fa5af 100644 --- a/src/infra/llm/anthropic.ts +++ b/src/infra/llm/anthropic.ts @@ -3,12 +3,13 @@ export { generateContentWithAnthropic }; import Anthropic from "@anthropic-ai/sdk"; import { type Config, type AnthropicEffort } from "@/domain/config/config"; -import { type GenerateContentParams } from "@/domain/llm/router"; +import { type GenerateContentParams, type ProviderGeneratedContent, type TokenUsage } from "@/domain/llm/router"; import { Future } from "@/libs/future"; import { anthropicOAuthHeaders, CLAUDE_CODE_SYSTEM_PROMPT } from "@/infra/auth/anthropic"; import { absurd } from "@/libs/types"; import { extractResponse } from "@/domain/llm/response-parser"; import { unsupportedAuth } from "@/domain/llm/auth-error"; +import { withTransientRetry } from "@/infra/llm/retry"; import { Just, fromOptional, type Maybe } from "@/libs/maybe"; type AnthropicConfig = Extract; @@ -20,6 +21,15 @@ const extractAnthropicText = (content: Anthropic.ContentBlock[]): string => .map((b) => b.text) .join(""); +const toTokenUsage = (usage: Anthropic.Usage): TokenUsage => { + const input = usage.input_tokens + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0); + return { + input: Just(input), + output: Just(usage.output_tokens), + total: Just(input + usage.output_tokens) + }; +}; + const buildParams = ( model: string, system: Maybe, @@ -42,31 +52,52 @@ const buildSetupTokenSystem = (instruction: Maybe): SystemParam => { type: "text", text } ]); -const callAnthropicWithApiKey = (apiKey: string, model: string, effort: Maybe, params: GenerateContentParams): Future => - Future.attemptP(async () => { - const client = new Anthropic({ apiKey }); - const stream = client.messages.stream(buildParams(model, fromOptional(params.systemInstruction), effort, params)); - return await stream.finalMessage(); - }) - .mapRej((error) => new Error(`Failed to create Anthropic message: ${error instanceof Error ? error.message : String(error)}`)) - .chain((message) => extractResponse({ text: Just(extractAnthropicText(message.content)) })); +const callAnthropicWithApiKey = ( + apiKey: string, + model: string, + effort: Maybe, + params: GenerateContentParams +): Future => + withTransientRetry(() => + Future.attemptP(async () => { + const client = new Anthropic({ apiKey, maxRetries: 3, timeout: 120_000 }); + const stream = client.messages.stream(buildParams(model, fromOptional(params.systemInstruction), effort, params)); + return await stream.finalMessage(); + }).mapRej((error) => new Error(`Failed to create Anthropic message: ${error instanceof Error ? error.message : String(error)}`, { cause: error })) + ).chain((message) => + extractResponse({ text: Just(extractAnthropicText(message.content)) }).map((text) => ({ + text, + tokens: Just(toTokenUsage(message.usage)) + })) + ); const callAnthropicWithSetupToken = ( authToken: string, model: string, effort: Maybe, params: GenerateContentParams -): Future => - Future.attemptP(async () => { - const client = new Anthropic({ apiKey: null, authToken, defaultHeaders: anthropicOAuthHeaders() }); - const system = Just(buildSetupTokenSystem(fromOptional(params.systemInstruction))); - const stream = client.messages.stream(buildParams(model, system, effort, params)); - return await stream.finalMessage(); - }) - .mapRej((error) => new Error(`Failed to create Anthropic message: ${error instanceof Error ? error.message : String(error)}`)) - .chain((message) => extractResponse({ text: Just(extractAnthropicText(message.content)) })); +): Future => + withTransientRetry(() => + Future.attemptP(async () => { + const client = new Anthropic({ + apiKey: null, + authToken, + defaultHeaders: anthropicOAuthHeaders(), + maxRetries: 3, + timeout: 120_000 + }); + const system = Just(buildSetupTokenSystem(fromOptional(params.systemInstruction))); + const stream = client.messages.stream(buildParams(model, system, effort, params)); + return await stream.finalMessage(); + }).mapRej((error) => new Error(`Failed to create Anthropic message: ${error instanceof Error ? error.message : String(error)}`, { cause: error })) + ).chain((message) => + extractResponse({ text: Just(extractAnthropicText(message.content)) }).map((text) => ({ + text, + tokens: Just(toTokenUsage(message.usage)) + })) + ); -const generateContentWithAnthropic = (config: AnthropicConfig, params: GenerateContentParams): Future => { +const generateContentWithAnthropic = (config: AnthropicConfig, params: GenerateContentParams): Future => { switch (config.auth_method.type) { case "api_key": return callAnthropicWithApiKey(config.auth_method.content, config.model, config.effort, params); diff --git a/src/infra/llm/gemini.ts b/src/infra/llm/gemini.ts index d5473d8..a8d7acc 100644 --- a/src/infra/llm/gemini.ts +++ b/src/infra/llm/gemini.ts @@ -3,7 +3,7 @@ export { type GeminiAuthCredentials, generateContentWithGemini, getAuthCredentia import { GoogleGenAI, ThinkingLevel, type Content, type GenerateContentConfig, type GenerateContentResponse, type GenerationConfig } from "@google/genai"; import { type Config, type OAuthTokens, type GeminiEffort } from "@/domain/config/config"; -import { type GenerateContentParams } from "@/domain/llm/router"; +import { type GenerateContentParams, type ProviderGeneratedContent, type TokenUsage } from "@/domain/llm/router"; import { Future } from "@/libs/future"; import { getAccessToken } from "@/infra/auth/google"; import { Just, Nothing, fromOptional, type Maybe } from "@/libs/maybe"; @@ -20,6 +20,13 @@ type OAuthRequestBody = { generationConfig?: GenerationConfig; }; +const toTokenUsage = (usage: GenerateContentResponse["usageMetadata"]): Maybe => + fromOptional(usage).map((u) => ({ + input: fromOptional(u.promptTokenCount), + output: fromOptional(u.candidatesTokenCount), + total: fromOptional(u.totalTokenCount) + })); + const getAuthCredentials = (config: Config): Maybe => { switch (config.ai.auth_method.type) { case "google_oauth": @@ -46,50 +53,65 @@ const buildOAuthBody = (effort: Maybe, params: GenerateContentPara return fromOptional(params.systemInstruction).maybe(core, (s) => ({ ...core, systemInstruction: { parts: [{ text: s }] } })); }; -const extractSSEEventText = (event: string): string => { +const extractSSEEvent = (event: string): ProviderGeneratedContent => { const dataLine = event.split("\n").find((l) => l.startsWith("data: ")); - if (!dataLine) return ""; + if (!dataLine) return { text: "", tokens: Nothing() }; const json = JSON.parse(dataLine.slice(6)) as GenerateContentResponse; - return json.candidates?.[0]?.content?.parts?.[0]?.text ?? ""; + return { + text: json.candidates?.[0]?.content?.parts?.[0]?.text ?? "", + tokens: toTokenUsage(json.usageMetadata) + }; }; -const accumulateSSEText = async (response: Response): Promise => { +const accumulateSSEContent = async (response: Response): Promise => { if (!response.ok) throw new Error(`Gemini API error (${response.status}): ${await response.text()}`); if (!response.body) throw new Error("Gemini stream returned no body"); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; let text = ""; + let tokens: Maybe = Nothing(); while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const events = buffer.split(/\r?\n\r?\n/); buffer = events.pop() ?? ""; - for (const ev of events) text += extractSSEEventText(ev); + for (const ev of events) { + const chunk = extractSSEEvent(ev); + text += chunk.text; + tokens = chunk.tokens.alt(tokens); + } } - return text; + return { text, tokens }; }; -const generateContentWithApiKey = (apiKey: string, model: string, effort: Maybe, params: GenerateContentParams): Future => +const generateContentWithApiKey = ( + apiKey: string, + model: string, + effort: Maybe, + params: GenerateContentParams +): Future => Future.attemptP(async () => { const ai = new GoogleGenAI({ apiKey }); const stream = await ai.models.generateContentStream({ model, contents: params.prompt, config: buildSDKConfig(effort, params) }); let text = ""; + let tokens: Maybe = Nothing(); for await (const chunk of stream) { if (chunk.text) text += chunk.text; + tokens = toTokenUsage(chunk.usageMetadata).alt(tokens); } - return text; + return { text, tokens }; }) .mapRej((error) => new Error(`Failed to create Gemini content: ${error instanceof Error ? error.message : String(error)}`)) - .chain((text) => extractResponse({ text: fromOptional(text) })); + .chain((content) => extractResponse({ text: fromOptional(content.text) }).map((text) => ({ ...content, text }))); const generateContentWithOAuth = ( tokens: OAuthTokens, model: string, effort: Maybe, params: GenerateContentParams -): Future => +): Future => getAccessToken(tokens).chain((accessToken) => Future.attemptP(async () => { const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:streamGenerateContent?alt=sse`; @@ -98,13 +120,13 @@ const generateContentWithOAuth = ( headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" }, body: JSON.stringify(buildOAuthBody(effort, params)) }); - return await accumulateSSEText(response); + return await accumulateSSEContent(response); }) .mapRej((error) => new Error(`Failed to create Gemini content: ${error instanceof Error ? error.message : String(error)}`)) - .chain((text) => extractResponse({ text: fromOptional(text) })) + .chain((content) => extractResponse({ text: fromOptional(content.text) }).map((text) => ({ ...content, text }))) ); -const generateContentWithGemini = (config: GeminiConfig, params: GenerateContentParams): Future => { +const generateContentWithGemini = (config: GeminiConfig, params: GenerateContentParams): Future => { switch (config.auth_method.type) { case "api_key": return generateContentWithApiKey(config.auth_method.content, config.model, config.effort, params); diff --git a/src/infra/llm/openai.ts b/src/infra/llm/openai.ts index d34511d..bddc9a9 100644 --- a/src/infra/llm/openai.ts +++ b/src/infra/llm/openai.ts @@ -3,13 +3,13 @@ export { generateContentWithOpenAI }; import OpenAI from "openai"; import { type Config, type OpenAIEffort } from "@/domain/config/config"; -import { type GenerateContentParams } from "@/domain/llm/router"; +import { type GenerateContentParams, type ProviderGeneratedContent, type TokenUsage } from "@/domain/llm/router"; import { Future } from "@/libs/future"; import { getOpenAIAccessToken } from "@/infra/auth/openai"; import { extractResponse } from "@/domain/llm/response-parser"; import { unsupportedAuth } from "@/domain/llm/auth-error"; import { absurd } from "@/libs/types"; -import { fromOptional, type Maybe } from "@/libs/maybe"; +import { Just, fromOptional, type Maybe } from "@/libs/maybe"; type OpenAIConfig = Extract; type StreamBundle = { @@ -30,6 +30,12 @@ const extractStreamText = (bundle: StreamBundle): Maybe => { const openaiReasoning = (effort: Maybe): Maybe => effort.map((e) => ({ effort: e })); +const toTokenUsage = (usage: OpenAI.Responses.ResponseUsage): TokenUsage => ({ + input: Just(usage.input_tokens), + output: Just(usage.output_tokens), + total: Just(usage.total_tokens) +}); + const buildStreamParams = (model: string, effort: Maybe, params: GenerateContentParams): OpenAI.Responses.ResponseCreateParamsStreaming => { const core: OpenAI.Responses.ResponseCreateParamsStreaming = { model, @@ -41,7 +47,12 @@ const buildStreamParams = (model: string, effort: Maybe, params: G return openaiReasoning(effort).maybe(core, (r) => ({ ...core, reasoning: r })); }; -const callOpenAIStream = (client: OpenAI, model: string, effort: Maybe, params: GenerateContentParams): Future => +const callOpenAIStream = ( + client: OpenAI, + model: string, + effort: Maybe, + params: GenerateContentParams +): Future => Future.attemptP(async () => { const stream = client.responses.stream(buildStreamParams(model, effort, params)); @@ -60,15 +71,29 @@ const callOpenAIStream = (client: OpenAI, model: string, effort: Maybe new Error(`Failed to create OpenAI response: ${error instanceof Error ? error.message : String(error)}`)) - .chain((bundle) => extractResponse({ text: extractStreamText(bundle) })); + .chain((bundle) => + extractResponse({ text: extractStreamText(bundle) }).map((text) => ({ + text, + tokens: fromOptional(bundle.response.usage).map(toTokenUsage) + })) + ); -const callOpenAIWithApiKey = (apiKey: string, model: string, effort: Maybe, params: GenerateContentParams): Future => - callOpenAIStream(new OpenAI({ apiKey }), model, effort, params); +const callOpenAIWithApiKey = ( + apiKey: string, + model: string, + effort: Maybe, + params: GenerateContentParams +): Future => callOpenAIStream(new OpenAI({ apiKey }), model, effort, params); -const callOpenAIWithOAuth = (authToken: string, model: string, effort: Maybe, params: GenerateContentParams): Future => +const callOpenAIWithOAuth = ( + authToken: string, + model: string, + effort: Maybe, + params: GenerateContentParams +): Future => callOpenAIStream(new OpenAI({ baseURL: "https://chatgpt.com/backend-api/codex", apiKey: authToken }), model, effort, params); -const generateContentWithOpenAI = (config: OpenAIConfig, params: GenerateContentParams): Future => { +const generateContentWithOpenAI = (config: OpenAIConfig, params: GenerateContentParams): Future => { switch (config.auth_method.type) { case "api_key": return callOpenAIWithApiKey(config.auth_method.content, config.model, config.effort, params); diff --git a/src/infra/llm/retry.ts b/src/infra/llm/retry.ts new file mode 100644 index 0000000..d2ad980 --- /dev/null +++ b/src/infra/llm/retry.ts @@ -0,0 +1,66 @@ +export { isTransientLLMError, withTransientRetry }; + +import { Future } from "@/libs/future"; + +const TRANSIENT_MESSAGE_FRAGMENTS = [ + "terminated", + "socket hang up", + "ECONNRESET", + "ETIMEDOUT", + "EAI_AGAIN", + "UND_ERR_SOCKET", + "network error", + "fetch failed" +]; + +const TRANSIENT_HTTP_STATUSES = new Set([408, 429, 500, 502, 503, 504, 529]); + +const messageMatchesTransient = (message: string): boolean => { + const lower = message.toLowerCase(); + return TRANSIENT_MESSAGE_FRAGMENTS.some((fragment) => lower.includes(fragment.toLowerCase())); +}; + +type ErrorLike = { name?: unknown; message?: unknown; status?: unknown; cause?: unknown }; + +const isTransientByName = (e: ErrorLike): boolean => + typeof e.name === "string" && (e.name === "APIConnectionError" || e.name === "APIConnectionTimeoutError"); + +const isTransientByStatus = (e: ErrorLike): boolean => typeof e.status === "number" && TRANSIENT_HTTP_STATUSES.has(e.status); + +const isTransientByMessage = (e: ErrorLike): boolean => typeof e.message === "string" && messageMatchesTransient(e.message); + +const isTransientObject = (e: ErrorLike): boolean => + isTransientByName(e) || isTransientByStatus(e) || isTransientByMessage(e) || (e.cause !== undefined && isTransientLLMError(e.cause)); + +const isTransientLLMError = (err: unknown): boolean => { + if (err === null || err === undefined) return false; + if (typeof err === "string") return messageMatchesTransient(err); + if (typeof err === "object") return isTransientObject(err as ErrorLike); + return false; +}; + +type RetryOpts = { + retries?: number; + baseMs?: number; + capMs?: number; + label?: string; +}; + +const withTransientRetry = (make: () => Future, opts: RetryOpts = {}): Future => { + const retries = opts.retries ?? 2; + const baseMs = opts.baseMs ?? 500; + const capMs = opts.capMs ?? 1500; + const label = opts.label ?? "llm"; + + const attempt = (n: number): Future => + make().chainRej((err) => { + if (n >= retries || !isTransientLLMError(err)) return Future.reject(err); + const exp = Math.min(capMs, baseMs * 2 ** n); + const jitter = Math.random() * (baseMs / 2); + const delay = exp + jitter; + process.stderr.write(`[${label}] transient error, retrying (${n + 1}/${retries})…\n`); + return Future.resolveAfter(delay, undefined).chain(() => attempt(n + 1)); + }); + + return attempt(0); +}; diff --git a/src/infra/ui/push-note.ts b/src/infra/ui/push-note.ts index 43c3da3..f6a5aed 100644 --- a/src/infra/ui/push-note.ts +++ b/src/infra/ui/push-note.ts @@ -1,12 +1,20 @@ -export { renderPushNote, type PushMetadata }; +export { renderCommitNote, renderPushNote, type CommitNoteMetadata, type PushMetadata }; import * as p from "@clack/prompts"; import type { CommitMetadata, PushRange } from "@/infra/git/repo"; import type { PrLookup } from "@/infra/github/pr"; +import type { LlmRequestMetadata } from "@/domain/llm/router"; import type { Maybe } from "@/libs/maybe"; import { absurd } from "@/libs/types"; +type RequestMetadata = Maybe; + +type CommitNoteMetadata = { + commit: Maybe; + request: RequestMetadata; +}; + type PushMetadata = { commit: Maybe; localBranch: Maybe; @@ -14,10 +22,17 @@ type PushMetadata = { remoteUrl: Maybe; range: Maybe; pr: PrLookup; + request: RequestMetadata; }; const formatDate = (d: Date): string => d.toISOString().slice(0, 16).replace("T", " "); +const formatDuration = (ms: number): string => (ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`); + +const formatNumber = (n: number): string => n.toLocaleString("en-US"); + +const renderModelLine = (metadata: LlmRequestMetadata): string => `model ${metadata.model.model} with ${metadata.model.effort} effort`; + const renderPrLine = (lookup: PrLookup): string[] => { switch (lookup.type) { case "found": @@ -39,13 +54,40 @@ const renderCommitLines = (commit: Maybe): string[] => `date ${formatDate(value.date)}` ]); +const renderRequestLines = (request: RequestMetadata): string[] => + request.maybe([], (value) => [ + renderModelLine(value), + `request ${formatDuration(value.durationMs)}`, + value.tokens.maybe( + "tokens unavailable", + (tokens) => + `tokens input ${tokens.input.maybe("?", formatNumber)} output ${tokens.output.maybe("?", formatNumber)} total ${tokens.total.maybe("?", formatNumber)}` + ) + ]); + +const renderCommitNote = (m: CommitNoteMetadata): void => { + const body = [...renderCommitLines(m.commit), ...renderRequestLines(m.request)].join("\n"); + + if (!body) return; + + p.note(body, "Committed"); +}; + const renderPushNote = (m: PushMetadata): void => { const branchLine = m.localBranch.maybe([], (branch) => [`branch ${branch}`]); const baseLine = m.baseBranch.maybe([], (base) => [`base ${base}`]); const remoteLine = m.remoteUrl.maybe([], (url) => [`remote ${url}`]); const rangeLine = m.range.maybe([], (range) => [`range ${range.before}..${range.after}`]); - const body = [...renderCommitLines(m.commit), ...branchLine, ...baseLine, ...remoteLine, ...rangeLine, ...renderPrLine(m.pr)].join("\n"); + const body = [ + ...renderCommitLines(m.commit), + ...branchLine, + ...baseLine, + ...remoteLine, + ...rangeLine, + ...renderRequestLines(m.request), + ...renderPrLine(m.pr) + ].join("\n"); if (!body) return; From 5e741dc8b2bc5fadb7bb88f0826f8cce546ddd19 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 9 May 2026 09:42:24 -0300 Subject: [PATCH 3/5] Replace custom transient retry with SDK-native retry and timeout - Delete `src/infra/llm/retry.ts` and drop `withTransientRetry` wrappers from both Anthropic auth paths. - Add `maxRetries: 3` and 120s timeout to OpenAI clients for API key and OAuth flows. - Configure Gemini `GoogleGenAI` with 120s timeout and 3 retry attempts via `httpOptions`. - Switch Gemini API key path from streamed iteration to a single `generateContent` call. - Switch Gemini OAuth path from `:streamGenerateContent?alt=sse` to `:generateContent` and parse the JSON response directly. - Replace `extractSSEEvent` and `accumulateSSEContent` with `parseOAuthResponse`, and share mapping via new `extractGeminiText` and `toGeneratedContent` helpers. --- src/infra/llm/anthropic.ts | 63 ++++++++++++++++++------------------ src/infra/llm/gemini.ts | 65 ++++++++++++++----------------------- src/infra/llm/openai.ts | 9 ++++-- src/infra/llm/retry.ts | 66 -------------------------------------- 4 files changed, 62 insertions(+), 141 deletions(-) delete mode 100644 src/infra/llm/retry.ts diff --git a/src/infra/llm/anthropic.ts b/src/infra/llm/anthropic.ts index 26fa5af..c391218 100644 --- a/src/infra/llm/anthropic.ts +++ b/src/infra/llm/anthropic.ts @@ -9,7 +9,6 @@ import { anthropicOAuthHeaders, CLAUDE_CODE_SYSTEM_PROMPT } from "@/infra/auth/a import { absurd } from "@/libs/types"; import { extractResponse } from "@/domain/llm/response-parser"; import { unsupportedAuth } from "@/domain/llm/auth-error"; -import { withTransientRetry } from "@/infra/llm/retry"; import { Just, fromOptional, type Maybe } from "@/libs/maybe"; type AnthropicConfig = Extract; @@ -58,18 +57,18 @@ const callAnthropicWithApiKey = ( effort: Maybe, params: GenerateContentParams ): Future => - withTransientRetry(() => - Future.attemptP(async () => { - const client = new Anthropic({ apiKey, maxRetries: 3, timeout: 120_000 }); - const stream = client.messages.stream(buildParams(model, fromOptional(params.systemInstruction), effort, params)); - return await stream.finalMessage(); - }).mapRej((error) => new Error(`Failed to create Anthropic message: ${error instanceof Error ? error.message : String(error)}`, { cause: error })) - ).chain((message) => - extractResponse({ text: Just(extractAnthropicText(message.content)) }).map((text) => ({ - text, - tokens: Just(toTokenUsage(message.usage)) - })) - ); + Future.attemptP(async () => { + const client = new Anthropic({ apiKey, maxRetries: 3, timeout: 120_000 }); + const stream = client.messages.stream(buildParams(model, fromOptional(params.systemInstruction), effort, params)); + return await stream.finalMessage(); + }) + .mapRej((error) => new Error(`Failed to create Anthropic message: ${error instanceof Error ? error.message : String(error)}`, { cause: error })) + .chain((message) => + extractResponse({ text: Just(extractAnthropicText(message.content)) }).map((text) => ({ + text, + tokens: Just(toTokenUsage(message.usage)) + })) + ); const callAnthropicWithSetupToken = ( authToken: string, @@ -77,25 +76,25 @@ const callAnthropicWithSetupToken = ( effort: Maybe, params: GenerateContentParams ): Future => - withTransientRetry(() => - Future.attemptP(async () => { - const client = new Anthropic({ - apiKey: null, - authToken, - defaultHeaders: anthropicOAuthHeaders(), - maxRetries: 3, - timeout: 120_000 - }); - const system = Just(buildSetupTokenSystem(fromOptional(params.systemInstruction))); - const stream = client.messages.stream(buildParams(model, system, effort, params)); - return await stream.finalMessage(); - }).mapRej((error) => new Error(`Failed to create Anthropic message: ${error instanceof Error ? error.message : String(error)}`, { cause: error })) - ).chain((message) => - extractResponse({ text: Just(extractAnthropicText(message.content)) }).map((text) => ({ - text, - tokens: Just(toTokenUsage(message.usage)) - })) - ); + Future.attemptP(async () => { + const client = new Anthropic({ + apiKey: null, + authToken, + defaultHeaders: anthropicOAuthHeaders(), + maxRetries: 3, + timeout: 120_000 + }); + const system = Just(buildSetupTokenSystem(fromOptional(params.systemInstruction))); + const stream = client.messages.stream(buildParams(model, system, effort, params)); + return await stream.finalMessage(); + }) + .mapRej((error) => new Error(`Failed to create Anthropic message: ${error instanceof Error ? error.message : String(error)}`, { cause: error })) + .chain((message) => + extractResponse({ text: Just(extractAnthropicText(message.content)) }).map((text) => ({ + text, + tokens: Just(toTokenUsage(message.usage)) + })) + ); const generateContentWithAnthropic = (config: AnthropicConfig, params: GenerateContentParams): Future => { switch (config.auth_method.type) { diff --git a/src/infra/llm/gemini.ts b/src/infra/llm/gemini.ts index a8d7acc..177cef4 100644 --- a/src/infra/llm/gemini.ts +++ b/src/infra/llm/gemini.ts @@ -27,6 +27,14 @@ const toTokenUsage = (usage: GenerateContentResponse["usageMetadata"]): Maybe + response.text ?? response.candidates?.[0]?.content?.parts?.map((part) => part.text ?? "").join("") ?? ""; + +const toGeneratedContent = (response: GenerateContentResponse): ProviderGeneratedContent => ({ + text: extractGeminiText(response), + tokens: toTokenUsage(response.usageMetadata) +}); + const getAuthCredentials = (config: Config): Maybe => { switch (config.ai.auth_method.type) { case "google_oauth": @@ -53,37 +61,15 @@ const buildOAuthBody = (effort: Maybe, params: GenerateContentPara return fromOptional(params.systemInstruction).maybe(core, (s) => ({ ...core, systemInstruction: { parts: [{ text: s }] } })); }; -const extractSSEEvent = (event: string): ProviderGeneratedContent => { - const dataLine = event.split("\n").find((l) => l.startsWith("data: ")); - if (!dataLine) return { text: "", tokens: Nothing() }; - const json = JSON.parse(dataLine.slice(6)) as GenerateContentResponse; - return { - text: json.candidates?.[0]?.content?.parts?.[0]?.text ?? "", - tokens: toTokenUsage(json.usageMetadata) - }; -}; - -const accumulateSSEContent = async (response: Response): Promise => { +const parseOAuthResponse = async (response: Response): Promise => { if (!response.ok) throw new Error(`Gemini API error (${response.status}): ${await response.text()}`); - if (!response.body) throw new Error("Gemini stream returned no body"); - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - let text = ""; - let tokens: Maybe = Nothing(); - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const events = buffer.split(/\r?\n\r?\n/); - buffer = events.pop() ?? ""; - for (const ev of events) { - const chunk = extractSSEEvent(ev); - text += chunk.text; - tokens = chunk.tokens.alt(tokens); - } - } - return { text, tokens }; + + // The REST OAuth path receives the same JSON wire shape that @google/genai maps + // to GenerateContentResponse for API-key calls. Response.json() cannot prove that + // shape to TypeScript, so this cast keeps both auth paths on one metadata mapper. + // If this breaks, compare the REST payload with the fields used below: + // response.text, candidates[].content.parts[].text, and usageMetadata token counts. + return toGeneratedContent((await response.json()) as GenerateContentResponse); }; const generateContentWithApiKey = ( @@ -93,15 +79,12 @@ const generateContentWithApiKey = ( params: GenerateContentParams ): Future => Future.attemptP(async () => { - const ai = new GoogleGenAI({ apiKey }); - const stream = await ai.models.generateContentStream({ model, contents: params.prompt, config: buildSDKConfig(effort, params) }); - let text = ""; - let tokens: Maybe = Nothing(); - for await (const chunk of stream) { - if (chunk.text) text += chunk.text; - tokens = toTokenUsage(chunk.usageMetadata).alt(tokens); - } - return { text, tokens }; + const ai = new GoogleGenAI({ + apiKey, + httpOptions: { timeout: 120_000, retryOptions: { attempts: 3 } } + }); + const response = await ai.models.generateContent({ model, contents: params.prompt, config: buildSDKConfig(effort, params) }); + return toGeneratedContent(response); }) .mapRej((error) => new Error(`Failed to create Gemini content: ${error instanceof Error ? error.message : String(error)}`)) .chain((content) => extractResponse({ text: fromOptional(content.text) }).map((text) => ({ ...content, text }))); @@ -114,13 +97,13 @@ const generateContentWithOAuth = ( ): Future => getAccessToken(tokens).chain((accessToken) => Future.attemptP(async () => { - const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:streamGenerateContent?alt=sse`; + const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`; const response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" }, body: JSON.stringify(buildOAuthBody(effort, params)) }); - return await accumulateSSEContent(response); + return await parseOAuthResponse(response); }) .mapRej((error) => new Error(`Failed to create Gemini content: ${error instanceof Error ? error.message : String(error)}`)) .chain((content) => extractResponse({ text: fromOptional(content.text) }).map((text) => ({ ...content, text }))) diff --git a/src/infra/llm/openai.ts b/src/infra/llm/openai.ts index bddc9a9..e8bbf9b 100644 --- a/src/infra/llm/openai.ts +++ b/src/infra/llm/openai.ts @@ -83,7 +83,7 @@ const callOpenAIWithApiKey = ( model: string, effort: Maybe, params: GenerateContentParams -): Future => callOpenAIStream(new OpenAI({ apiKey }), model, effort, params); +): Future => callOpenAIStream(new OpenAI({ apiKey, maxRetries: 3, timeout: 120_000 }), model, effort, params); const callOpenAIWithOAuth = ( authToken: string, @@ -91,7 +91,12 @@ const callOpenAIWithOAuth = ( effort: Maybe, params: GenerateContentParams ): Future => - callOpenAIStream(new OpenAI({ baseURL: "https://chatgpt.com/backend-api/codex", apiKey: authToken }), model, effort, params); + callOpenAIStream( + new OpenAI({ baseURL: "https://chatgpt.com/backend-api/codex", apiKey: authToken, maxRetries: 3, timeout: 120_000 }), + model, + effort, + params + ); const generateContentWithOpenAI = (config: OpenAIConfig, params: GenerateContentParams): Future => { switch (config.auth_method.type) { diff --git a/src/infra/llm/retry.ts b/src/infra/llm/retry.ts deleted file mode 100644 index d2ad980..0000000 --- a/src/infra/llm/retry.ts +++ /dev/null @@ -1,66 +0,0 @@ -export { isTransientLLMError, withTransientRetry }; - -import { Future } from "@/libs/future"; - -const TRANSIENT_MESSAGE_FRAGMENTS = [ - "terminated", - "socket hang up", - "ECONNRESET", - "ETIMEDOUT", - "EAI_AGAIN", - "UND_ERR_SOCKET", - "network error", - "fetch failed" -]; - -const TRANSIENT_HTTP_STATUSES = new Set([408, 429, 500, 502, 503, 504, 529]); - -const messageMatchesTransient = (message: string): boolean => { - const lower = message.toLowerCase(); - return TRANSIENT_MESSAGE_FRAGMENTS.some((fragment) => lower.includes(fragment.toLowerCase())); -}; - -type ErrorLike = { name?: unknown; message?: unknown; status?: unknown; cause?: unknown }; - -const isTransientByName = (e: ErrorLike): boolean => - typeof e.name === "string" && (e.name === "APIConnectionError" || e.name === "APIConnectionTimeoutError"); - -const isTransientByStatus = (e: ErrorLike): boolean => typeof e.status === "number" && TRANSIENT_HTTP_STATUSES.has(e.status); - -const isTransientByMessage = (e: ErrorLike): boolean => typeof e.message === "string" && messageMatchesTransient(e.message); - -const isTransientObject = (e: ErrorLike): boolean => - isTransientByName(e) || isTransientByStatus(e) || isTransientByMessage(e) || (e.cause !== undefined && isTransientLLMError(e.cause)); - -const isTransientLLMError = (err: unknown): boolean => { - if (err === null || err === undefined) return false; - if (typeof err === "string") return messageMatchesTransient(err); - if (typeof err === "object") return isTransientObject(err as ErrorLike); - return false; -}; - -type RetryOpts = { - retries?: number; - baseMs?: number; - capMs?: number; - label?: string; -}; - -const withTransientRetry = (make: () => Future, opts: RetryOpts = {}): Future => { - const retries = opts.retries ?? 2; - const baseMs = opts.baseMs ?? 500; - const capMs = opts.capMs ?? 1500; - const label = opts.label ?? "llm"; - - const attempt = (n: number): Future => - make().chainRej((err) => { - if (n >= retries || !isTransientLLMError(err)) return Future.reject(err); - const exp = Math.min(capMs, baseMs * 2 ** n); - const jitter = Math.random() * (baseMs / 2); - const delay = exp + jitter; - process.stderr.write(`[${label}] transient error, retrying (${n + 1}/${retries})…\n`); - return Future.resolveAfter(delay, undefined).chain(() => attempt(n + 1)); - }); - - return attempt(0); -}; From b036fe4df4ab43dd88d1810ef1fcbcb9a534b4f8 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 9 May 2026 09:43:28 -0300 Subject: [PATCH 4/5] Bump version to 0.2.8 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 61b3698..d92ad3b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rafaeelricco/commit-tools", - "version": "0.2.7", + "version": "0.2.8", "type": "module", "bin": { "commit": "./dist/index.js" From 67721a724ba222b3df218c8f8e6418825663ae36 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 9 May 2026 10:01:55 -0300 Subject: [PATCH 5/5] Switch to Corepack-managed pnpm and add workspace config - Replace global `npm install -g pnpm` with `corepack enable` in PR validation workflow. - Use `pnpm install --frozen-lockfile` in CI to ensure reproducible installs. - Pin `packageManager` to `pnpm@10.33.0` in `package.json`. - Add `pnpm-workspace.yaml` defining the root package and allowing builds for `esbuild` and `protobufjs`. --- .github/workflows/pr-validate.yml | 12 ++++++------ package.json | 1 + pnpm-workspace.yaml | 6 ++++++ 3 files changed, 13 insertions(+), 6 deletions(-) create mode 100644 pnpm-workspace.yaml diff --git a/.github/workflows/pr-validate.yml b/.github/workflows/pr-validate.yml index 942eef8..1eea11f 100644 --- a/.github/workflows/pr-validate.yml +++ b/.github/workflows/pr-validate.yml @@ -19,11 +19,11 @@ jobs: with: node-version: "24" - - name: Install pnpm - run: npm install -g pnpm + - name: Enable Corepack + run: corepack enable - name: Install dependencies - run: pnpm install + run: pnpm install --frozen-lockfile - name: Typecheck run: pnpm run typecheck @@ -51,11 +51,11 @@ jobs: with: node-version: "24" - - name: Install pnpm - run: npm install -g pnpm + - name: Enable Corepack + run: corepack enable - name: Install dependencies - run: pnpm install + run: pnpm install --frozen-lockfile - name: Lint (cognitive complexity) run: pnpm run lint:ci diff --git a/package.json b/package.json index d92ad3b..f1ce9b0 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "@rafaeelricco/commit-tools", "version": "0.2.8", "type": "module", + "packageManager": "pnpm@10.33.0", "bin": { "commit": "./dist/index.js" }, diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..b52cd10 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,6 @@ +packages: + - . + +allowBuilds: + "esbuild@0.27.4": true + "protobufjs@7.5.5": true