Skip to content

feat: Add reasoning content separation for LLM providers#136

Merged
m-mizutani merged 18 commits into
gollem-dev:mainfrom
denkhaus:feature/thinking-content-separation
May 2, 2026
Merged

feat: Add reasoning content separation for LLM providers#136
m-mizutani merged 18 commits into
gollem-dev:mainfrom
denkhaus:feature/thinking-content-separation

Conversation

@denkhaus

@denkhaus denkhaus commented May 2, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR adds support for separating reasoning/thinking content from regular output across all LLM providers (Claude, OpenAI, Gemini). It enables applications to distinguish between internal model reasoning and final responses.

Motivation

Why this matters:

As LLMs with reasoning capabilities become more prevalent (Claude's extended thinking, OpenAI's o1/o3 models, Gemini's thinking models), applications need to:

  1. Display reasoning to users - Show the model's thought process for transparency and trust
  2. Separate concerns - Keep reasoning separate from final output for better UX
  3. Analytics & debugging - Track and analyze reasoning patterns separately
  4. Compliance - Some use cases require logging reasoning separately from responses

Without this separation:

  • Reasoning content is mixed with regular output
  • Applications cannot distinguish between internal thoughts and final answers
  • Poor user experience when reasoning is shown inline
  • Cannot properly store or analyze reasoning separately

Changes

Core Types (message.go) - ✨ NEW

  • Added MessageContentTypeReasoning constant ("reasoning")
  • Added ReasoningContent struct
  • Added NewReasoningContent() helper function
  • Added GetReasoningContent() helper function

Response Struct (llm.go) - ✨ NEW

  • Added Thoughts []string field to Response struct for storing reasoning content

Provider Implementations

Claude (llm/claude/):

  • ✅ Already supported - maps OfThinking blocks to MessageContentTypeReasoning
  • Extracts reasoning to Response.Thoughts array
  • Added tests for reasoning content handling

OpenAI (llm/openai/):

  • ✅ Already supported - maps ReasoningContent field to MessageContentTypeReasoning
  • Extracts reasoning to Response.Thoughts array
  • Added tests for reasoning models (o1/o3)

Gemini (llm/gemini/): ✨ NEW

  • NEW - Added support for thinking models
  • Maps Thought parts to MessageContentTypeReasoning
  • Extracts reasoning to Response.Thoughts array
  • Added MessageContentTypeReasoning case to convertContentToGemini()
  • Added comprehensive tests for thinking models

ExecuteResponse (execute_response.go) - ✨ NEW

  • Added Thoughts []string field for consistency

Middleware (middleware.go) - ✨ NEW

  • Added Thoughts []string field to handler context

Trace Package - ⚠️ BREAKING CHANGE

  • Renamed NewThinkingContent()NewReasoningContent()
  • Renamed NewRedactedThinkingContent()NewRedactedReasoningContent()
  • Updated type strings from "thinking" to "reasoning"
  • Updated all call sites

Terminology

Used "Reasoning" throughout (not "Thinking") as it better describes the content of thought (like "Text" describes content), making it more natural in English.

Breaking Changes

⚠️ One breaking change in the trace package:

The trace package functions have been renamed for consistency:

  • trace.NewThinkingContent()trace.NewReasoningContent()
  • trace.NewRedactedThinkingContent()trace.NewRedactedReasoningContent()

Migration guide:

// Old
trace.NewThinkingContent("thinking...")
trace.NewRedactedThinkingContent()

// New
trace.NewReasoningContent("thinking...")
trace.NewRedactedReasoningContent()

Note: This is a minimal breaking change affecting only the trace package. The core API additions are purely additive.

Testing

All tests pass successfully:

  • ✅ Core message type tests (message_test.go)
  • ✅ Claude provider tests (llm/claude/)
  • ✅ OpenAI provider tests (llm/openai/)
  • ✅ Gemini provider tests (llm/gemini/)
  • ✅ Trace package tests (trace/)

Test Coverage

Core Tests:

  • TestMessageContentTypeReasoning - Type constant validation
  • TestReasoningContent - Struct validation
  • TestNewReasoningContent - Content creation
  • TestGetReasoningContent - Content extraction

Provider Tests:

  • Claude: Reasoning content round-trip tests
  • OpenAI: o1/o3 reasoning model tests
  • Gemini: Thinking model tests (newly added)

Benefits

Consistent API - All three providers now handle reasoning content uniformly
Better UX - Applications can display reasoning separately from final output
Analytics ready - Reasoning can be tracked and analyzed separately
Future-proof - Ready for new reasoning models from any provider
Well-tested - Comprehensive test coverage across all providers
Minimal breaking changes - Only trace package affected

Example Usage

// Generate content with a reasoning model
resp, err := client.Generate(ctx, prompt)

// Access reasoning separately
for _, thought := range resp.Thoughts {
    log.Debug("Model reasoning: " + thought)
}

// Access final response
for _, text := range resp.Texts {
    fmt.Println(text)
}

Checklist

  • All tests pass
  • Comprehensive test coverage added
  • Breaking changes documented and minimal
  • All three providers supported (Claude, OpenAI, Gemini)
  • Trace package updated
  • Consistent terminology throughout
  • Documentation added to core types

Test User and others added 16 commits March 2, 2026 19:26
Sync with latest changes from m-mizutani/gollem upstream:
- chore: ignore .pnpm-store directory
- fix(trace): fall back to RootSpan when context has no active span
- Various dependency updates and fixes

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Local working documents (specs, plans) should not be committed to the repository.
Implement OpenAI reasoning content support following TDD approach:

- Add handling for ReasoningContent field in convertOpenAIMessage()
  to create gollem.ThinkingContent
- Add handling for MessageContentTypeThinking in convertMessageToOpenAI()
  to set the ReasoningContent field
- Add comprehensive test for round-trip conversion

The implementation mirrors the existing Claude thinking content support,
using the same ThinkingContent type for cross-provider compatibility.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Extract reasoning content from OpenAI API responses and populate the
Thinkings field in both ContentResponse and Response structs.

Changes:
- Add Thinkings field to gollem.Response struct
- Extract ReasoningContent from message responses in non-streaming mode
- Extract ReasoningContent from delta chunks in streaming mode
- Include reasoning content in assistant messages for history tracking
- Add tests to verify reasoning content extraction

This enables proper handling of thinking/reasoning content from GPT-5
models that support the reasoning_effort parameter.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Add Thinkings field to response initialization
- Extract thought parts to Thinkings array instead of skipping
- Convert thought parts to MessageContentTypeThinking in convert_message.go

This enables Gemini thinking models to properly separate internal
reasoning from final output, matching the behavior of Claude and
OpenAI reasoning models.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Update TestThoughtPartsExcludedFromResponse to expect MessageContentTypeThinking
- Add MessageContentTypeThinking case to convertContentToGemini
- All tests now pass with thinking content separation

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Rename all occurrences of "Thinkings" to "Thoughts" for better naming:
- "Thought" describes the content of reasoning (like "Text" describes content)
- More natural in English than "Thinkings"
- Consistent with Claude API terminology

Affected files:
- llm.go, execute_response.go, middleware.go (core types)
- llm/openai/client.go (OpenAI implementation)
- llm/gemini/client.go (Gemini implementation)
- All tests updated accordingly

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Complete renaming from "Thinking" to "Reasoning" terminology:
- Core types: MessageContentTypeThinking → MessageContentTypeReasoning
- Structs: ThinkingContent → ReasoningContent
- Functions: NewThinkingContent() → NewReasoningContent()
- Functions: GetThinkingContent() → GetReasoningContent()
- Trace package: NewThinkingContent() → NewReasoningContent()
- Trace package: NewRedactedThinkingContent() → NewRedactedReasoningContent()
- All tests updated accordingly

Rationale: "Reasoning" better describes the content/thought process
rather than the act of thinking.

All tests pass successfully.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a standardized way to handle LLM "thinking" or "reasoning" content across Claude, Gemini, and OpenAI providers by adding a new MessageContentTypeReasoning type and updating core response structures. The review feedback identifies critical issues in the Claude implementation where thinking signatures are lost and redacted blocks are mishandled, which would break multi-turn conversations. Additionally, a bug was found in the OpenAI conversion logic where reasoning content is overwritten rather than accumulated, and a local VS Code workspace file was mistakenly included in the repository.

Comment on lines +63 to +70
if block.OfThinking != nil {
return gollem.NewReasoningContent(block.OfThinking.Thinking)
}

// Handle redacted thinking blocks
if block.OfRedactedThinking != nil {
return gollem.NewReasoningContent(block.OfRedactedThinking.Data)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There are two issues with how Claude thinking blocks are converted to gollem.Message:

  1. Signature Loss: For normal thinking blocks (line 64), the Signature is lost. Anthropic requires this signature to be included when sending the thinking block back in subsequent messages for multi-turn conversations.
  2. Redacted Block Bug: For redacted thinking blocks (line 69), the signature (Data) is being stored in the Text field of ReasoningContent. This will cause the encrypted signature to be displayed as literal thought text to the user, and it will be incorrectly sent back as a normal thinking block in convertContentToClaude.

Consider using the Meta field to store the signature and the redacted status, similar to how it's handled in the Gemini implementation.

Comment thread llm/claude/convert_message.go Outdated
Comment on lines +249 to +260
case gollem.MessageContentTypeReasoning:
thinkingContent, err := content.GetReasoningContent()
if err != nil {
return anthropic.ContentBlockParamUnion{}, err
}
// Skip empty thinking content
if thinkingContent.Text == "" {
return anthropic.ContentBlockParamUnion{}, convert.ErrUnsupportedContentType
}
// Convert thinking to Claude thinking block
// NewThinkingBlock(signature, thinking) - note parameter order!
return anthropic.NewThinkingBlock("", thinkingContent.Text), nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When converting gollem.MessageContentTypeReasoning back to Claude format, the code always creates a normal ThinkingBlock with an empty signature. This breaks multi-turn conversations (which require the signature from the previous turn) and incorrectly handles redacted blocks (which should be sent back as RedactedThinkingBlock).

Comment thread llm/openai/convert_message.go Outdated
return nil, goerr.Wrap(err, "failed to get thinking content")
}
// Store reasoning content separately (will be set on the main message)
reasoningContent = thinkingContent.Text

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The reasoningContent variable is overwritten in each iteration of the loop. If a message contains multiple reasoning content blocks, only the last one will be preserved when converting back to the OpenAI format. It should be accumulated instead.

Suggested change
reasoningContent = thinkingContent.Text
reasoningContent += thinkingContent.Text

Comment thread this.code-workspace Outdated
Comment on lines +1 to +11
{
"folders": [
{
"path": "."
},
{
"path": "/home/denkhaus/dev/dendron"
}
],
"settings": {}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This VS Code workspace file should not be included in the repository, especially as it contains a hardcoded absolute path (/home/denkhaus/dev/dendron) specific to your local environment.

**Claude Implementation:**
- Add claudePartMeta struct to preserve thinking signatures and redacted status
- Store signature in Meta field for normal thinking blocks (multi-turn support)
- Store signature and redacted flag in Meta for redacted thinking blocks
- Fix round-trip conversion to restore signatures and handle redacted blocks correctly

**OpenAI Implementation:**
- Fix reasoning content accumulation (was overwriting, now using +=)

**Tests:**
- Update redacted thinking block test to expect empty text with meta field
- Remove VS Code workspace file that shouldn't be in repository

These changes address critical issues found in PR review that would break
multi-turn conversations and mishandle redacted thinking blocks.

All tests pass successfully.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@m-mizutani

Copy link
Copy Markdown
Collaborator

@denkhaus
Thanks for the PR. Substantive additions look great. I'd like to discuss the terminology choice before merging.

As of May 2026, the upstream providers use the following terminology:

  • Anthropic (Claude): thinking — "Extended thinking" / "Adaptive thinking", thinking_delta, OfThinking
  • Google (Gemini): thinking / thought — "Gemini thinking", part.thought, "Thought signatures" (required in Gemini 3)
  • OpenAI: reasoning — "Reasoning models", reasoning effort, ReasoningContent

Two of three vendors use thinking-family wording, and our existing public API already uses Thinking. Given there's no clear industry standard to align with, I'm hesitant about the breaking rename in the trace package without a stronger upside.

How do you feel about keeping Thinking as the vocabulary across the codebase (including the new additions)? Curious to hear your take.

@denkhaus

denkhaus commented May 2, 2026

Copy link
Copy Markdown
Contributor Author

Thank you for the thoughtful feedback and analysis!

You make excellent points:

  1. Provider alignment - Two of three vendors (Claude, Gemini) use "thinking" terminology
  2. API consistency - Existing public API already uses "Thinking"
  3. No clear industry standard - No compelling reason to diverge from existing patterns

I am happy to revert the terminology change from "reasoning" back to "thinking". This will:

  • Align with 2/3 providers instead of 1/3
  • Maintain consistency with existing trace package API
  • Reduce the scope of breaking changes
  • Keep the implementation more aligned with how providers actually name things

I will prepare a follow-up commit to change the naming back to "thinking" throughout the codebase.

The core implementation logic (signatures, redacted blocks, accumulation) will remain the same - only the naming will change.

Would you like me to proceed with this terminology update?

Per upstream maintainer request, revert terminology from "reasoning"
to "thinking" to align with:
- Provider terminology: 2/3 vendors (Claude, Gemini) use "thinking"
- Existing trace package API: already uses "Thinking"
- Industry consistency: no clear standard, prefer staying with existing

Changes:
- MessageContentTypeReasoning → MessageContentTypeThinking (value: "thinking")
- ReasoningContent → ThinkingContent
- NewReasoningContent() → NewThinkingContent()
- GetReasoningContent() → GetThinkingContent()
- Trace package: NewReasoningContent() → NewThinkingContent()
- Trace package: NewRedactedReasoningContent() → NewRedactedThinkingContent()

**Kept unchanged:**
- Response.Thoughts (already correct - plural like "Texts")

**OpenAI-specific:**
- Kept OpenAI library field names as ReasoningContent (library uses this)
- Only our wrapper code uses Thinking terminology

All tests pass successfully.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@m-mizutani

Copy link
Copy Markdown
Collaborator

@denkhaus
Yes, please proceed with the terminology update. I think keeping the naming aligned with existing provider APIs makes sense, and this seems like a good balance between improving the implementation and keeping the API change focused.

@denkhaus

denkhaus commented May 2, 2026

Copy link
Copy Markdown
Contributor Author

I hope the commit fits nicely in your vision of gollem. So with the last commit, I changed the wording back to "Thinking". However, in the response, I still use Thoughts []string instead of Thinkings []string, as this feels more natural. All conflicts and urgent issues should now be resolved. Please test the feature yourself anyway, as I can only test a limited number of LLM providers in my environment.

@m-mizutani

Copy link
Copy Markdown
Collaborator

@denkhaus
Thanks for the update. This looks good to me. Keeping Thinking as the project-level vocabulary while using Thoughts for the response field feels like a sensible balance.

I'll go ahead and merge this.

@m-mizutani m-mizutani merged commit 1f07bd3 into gollem-dev:main May 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants