diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml
index e0d14985..c4d3f53b 100644
--- a/.github/workflows/pr-test.yml
+++ b/.github/workflows/pr-test.yml
@@ -42,6 +42,13 @@ env:
TEST_MOAPI_SECRET: ${{ secrets.OPENAI_TEST_KEY }}
TEST_MOAPI_MIRROR: https://api.openai.com
+ # DeepSeek API Configuration
+ DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
+ DEEPSEEK_API_PROXY: ${{ secrets.DEEPSEEK_API_PROXY }}
+ DEEPSEEK_MODELS_R1: ${{ secrets.DEEPSEEK_MODELS_R1 }}
+ DEEPSEEK_MODELS_V3: ${{ secrets.DEEPSEEK_MODELS_V3 }}
+ DEEPSEEK_MODELS_V3_1: ${{ secrets.DEEPSEEK_MODELS_V3_1 }}
+
TAB_NAME: "::PET ADMIN"
PAGE_SIZE: "20"
PAGE_LINK: "https://yaoapps.com"
diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml
index 0cdf6cd6..37fb2b10 100644
--- a/.github/workflows/unit-test.yml
+++ b/.github/workflows/unit-test.yml
@@ -46,6 +46,13 @@ env:
TEST_MOAPI_SECRET: ${{ secrets.OPENAI_TEST_KEY }}
TEST_MOAPI_MIRROR: https://api.openai.com
+ # DeepSeek API Configuration
+ DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
+ DEEPSEEK_API_PROXY: ${{ secrets.DEEPSEEK_API_PROXY }}
+ DEEPSEEK_MODELS_R1: ${{ secrets.DEEPSEEK_MODELS_R1 }}
+ DEEPSEEK_MODELS_V3: ${{ secrets.DEEPSEEK_MODELS_V3 }}
+ DEEPSEEK_MODELS_V3_1: ${{ secrets.DEEPSEEK_MODELS_V3_1 }}
+
TAB_NAME: "::PET ADMIN"
PAGE_SIZE: "20"
PAGE_LINK: "https://yaoapps.com"
diff --git a/agent/context/types_llm.go b/agent/context/types_llm.go
index 94a37847..314b345c 100644
--- a/agent/context/types_llm.go
+++ b/agent/context/types_llm.go
@@ -61,6 +61,9 @@ type CompletionOptions struct {
Stream *bool `json:"stream,omitempty"` // If true, stream partial message deltas
StreamOptions *StreamOptions `json:"stream_options,omitempty"` // Options for streaming response
+ // Reasoning configuration (for reasoning models like o1, GPT-5)
+ ReasoningEffort *string `json:"reasoning_effort,omitempty"` // Reasoning effort level: "low", "medium", "high" (o1 and GPT-5 only)
+
// CUI Context information (from Context)
Route string `json:"route,omitempty"` // Route of the request for CUI context
Metadata map[string]interface{} `json:"metadata,omitempty"` // Metadata to pass to the page for CUI context
diff --git a/agent/llm/adapters/reasoning.go b/agent/llm/adapters/reasoning.go
index ba641032..5f4c636f 100644
--- a/agent/llm/adapters/reasoning.go
+++ b/agent/llm/adapters/reasoning.go
@@ -9,24 +9,53 @@ type ReasoningFormat string
const (
ReasoningFormatNone ReasoningFormat = "none" // No reasoning support
- ReasoningFormatOpenAI ReasoningFormat = "openai-o1" // OpenAI o1 format
- ReasoningFormatDeepSeek ReasoningFormat = "deepseek-r1" // DeepSeek R1 format
- ReasoningFormatGPTThink ReasoningFormat = "gpt-think" // Future GPT with thinking
+ ReasoningFormatOpenAI ReasoningFormat = "openai-o1" // OpenAI o1 format (hidden reasoning)
+ ReasoningFormatGPT5 ReasoningFormat = "gpt-5" // GPT-5 format (hidden reasoning)
+ ReasoningFormatDeepSeek ReasoningFormat = "deepseek-r1" // DeepSeek R1 format (visible reasoning)
)
// ReasoningAdapter handles reasoning content capability
-// Parses reasoning_content from different model formats
+// - Manages reasoning_effort parameter (o1, GPT-5)
+// - Extracts reasoning_tokens from usage
+// - Parses visible reasoning content (DeepSeek R1)
type ReasoningAdapter struct {
*BaseAdapter
- format ReasoningFormat
+ format ReasoningFormat
+ supportsEffort bool // Whether the model supports reasoning_effort parameter
}
// NewReasoningAdapter creates a new reasoning adapter
func NewReasoningAdapter(format ReasoningFormat) *ReasoningAdapter {
- return &ReasoningAdapter{
- BaseAdapter: NewBaseAdapter("ReasoningAdapter"),
- format: format,
+ supportsEffort := false
+
+ // Only OpenAI o1 and GPT-5 support reasoning_effort parameter
+ if format == ReasoningFormatOpenAI || format == ReasoningFormatGPT5 {
+ supportsEffort = true
}
+
+ return &ReasoningAdapter{
+ BaseAdapter: NewBaseAdapter("ReasoningAdapter"),
+ format: format,
+ supportsEffort: supportsEffort,
+ }
+}
+
+// PreprocessOptions handles reasoning_effort parameter
+func (a *ReasoningAdapter) PreprocessOptions(options *context.CompletionOptions) (*context.CompletionOptions, error) {
+ if options == nil {
+ return options, nil
+ }
+
+ // If model doesn't support reasoning_effort, remove it
+ if !a.supportsEffort && options.ReasoningEffort != nil {
+ // Model doesn't support reasoning_effort, remove the parameter
+ newOptions := *options
+ newOptions.ReasoningEffort = nil
+ return &newOptions, nil
+ }
+
+ // If model supports reasoning_effort, keep it as-is (user can set "low", "medium", or "high")
+ return options, nil
}
// ProcessStreamChunk processes streaming chunks with reasoning content
@@ -37,23 +66,27 @@ func (a *ReasoningAdapter) ProcessStreamChunk(chunkType context.StreamChunkType,
}
// TODO: Parse reasoning_content based on format
- // - OpenAI o1: reasoning_content field in delta
- // - DeepSeek R1: may have different format
- // - Extract and emit as ChunkThinking
+ // - OpenAI o1: No visible reasoning in stream (reasoning happens internally)
+ // - GPT-5: No visible reasoning in stream (reasoning happens internally)
+ // - DeepSeek R1: May have ... tags or reasoning_content field
return chunkType, data, nil
}
-// PostprocessResponse extracts reasoning content from the final response
+// PostprocessResponse extracts reasoning content and tokens from the final response
func (a *ReasoningAdapter) PostprocessResponse(response *context.CompletionResponse) (*context.CompletionResponse, error) {
if a.format == ReasoningFormatNone {
// No reasoning support
return response, nil
}
- // TODO: Extract reasoning content from response
- // - Set response.ReasoningContent if present
- // - Separate thinking from final answer
+ // Reasoning tokens are already extracted in Usage.CompletionTokensDetails.ReasoningTokens
+ // by the OpenAI response parser, no additional processing needed for o1/GPT-5
+
+ // TODO: For DeepSeek R1, extract visible reasoning content
+ // - Parse ... tags from content
+ // - Set response.ReasoningContent
+ // - Remove tags from response.Content (keep only final answer)
return response, nil
}
diff --git a/agent/llm/adapters/toolcall.go b/agent/llm/adapters/toolcall.go
index 9e58b325..a7790df7 100644
--- a/agent/llm/adapters/toolcall.go
+++ b/agent/llm/adapters/toolcall.go
@@ -64,4 +64,3 @@ func (a *ToolCallAdapter) PostprocessResponse(response *context.CompletionRespon
// - Add to response.ToolCalls
return response, nil
}
-
diff --git a/agent/llm/providers/factory.go b/agent/llm/providers/factory.go
index e4e0b260..4927485b 100644
--- a/agent/llm/providers/factory.go
+++ b/agent/llm/providers/factory.go
@@ -77,9 +77,9 @@ func DetectAPIFormat(conn connector.Connector) string {
// contains checks if a string contains a substring (case-insensitive helper)
func contains(s, substr string) bool {
- return len(s) >= len(substr) && (s == substr || len(s) > len(substr) &&
- (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr ||
- findSubstring(s, substr)))
+ return len(s) >= len(substr) && (s == substr || len(s) > len(substr) &&
+ (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr ||
+ findSubstring(s, substr)))
}
func findSubstring(s, substr string) bool {
diff --git a/agent/llm/providers/openai/deepseek_r1_test.go b/agent/llm/providers/openai/deepseek_r1_test.go
new file mode 100644
index 00000000..da5817da
--- /dev/null
+++ b/agent/llm/providers/openai/deepseek_r1_test.go
@@ -0,0 +1,383 @@
+package openai_test
+
+import (
+ gocontext "context"
+ "strings"
+ "testing"
+
+ "github.com/yaoapp/gou/connector"
+ "github.com/yaoapp/gou/plan"
+ "github.com/yaoapp/yao/agent/context"
+ "github.com/yaoapp/yao/agent/llm"
+ "github.com/yaoapp/yao/config"
+ "github.com/yaoapp/yao/openapi/oauth/types"
+ "github.com/yaoapp/yao/test"
+)
+
+// TestDeepSeekR1StreamBasic tests basic streaming completion with DeepSeek R1
+func TestDeepSeekR1StreamBasic(t *testing.T) {
+ test.Prepare(t, config.Conf)
+ defer test.Clean()
+
+ // Create connector from real configuration
+ conn, err := connector.Select("deepseek.r1")
+ if err != nil {
+ t.Fatalf("Failed to select connector: %v", err)
+ }
+
+ // Create LLM instance with capabilities
+ trueVal := true
+ falseVal := false
+ options := &context.CompletionOptions{
+ Capabilities: &context.ModelCapabilities{
+ Streaming: &trueVal,
+ Reasoning: &trueVal, // DeepSeek R1 supports reasoning
+ ToolCalls: &falseVal, // R1 doesn't support native tool calls
+ Vision: &falseVal,
+ Audio: &falseVal,
+ Multimodal: &falseVal,
+ },
+ }
+
+ llmInstance, err := llm.New(conn, options)
+ if err != nil {
+ t.Fatalf("Failed to create LLM instance: %v", err)
+ }
+
+ // Prepare messages with reasoning prompt (simple question for faster reasoning)
+ messages := []context.Message{
+ {
+ Role: context.RoleUser,
+ Content: "What is 2 + 2?",
+ },
+ }
+
+ // Set max tokens (higher for reasoning models to allow full reasoning + answer)
+ maxTokens := 500
+ options.MaxTokens = &maxTokens
+
+ // Create context
+ ctx := newDeepSeekTestContext("test-deepseek-r1-basic", "deepseek.r1")
+
+ // Track streaming chunks
+ var reasoningChunks []string
+ var contentChunks []string
+ handler := func(chunkType context.StreamChunkType, data []byte) int {
+ dataStr := string(data)
+ t.Logf("Stream chunk [%s]: %s", chunkType, dataStr)
+
+ // Track different chunk types
+ if chunkType == context.ChunkThinking {
+ reasoningChunks = append(reasoningChunks, dataStr)
+ } else if chunkType == context.ChunkText {
+ contentChunks = append(contentChunks, dataStr)
+ }
+
+ return 0 // Continue
+ }
+
+ // Call Stream
+ response, err := llmInstance.Stream(ctx, messages, options, handler)
+ if err != nil {
+ t.Fatalf("Stream failed: %v", err)
+ }
+
+ // Validate response
+ if response == nil {
+ t.Fatal("Response is nil")
+ }
+
+ if response.ID == "" {
+ t.Error("Response ID is empty")
+ }
+ if response.Model == "" {
+ t.Error("Response Model is empty")
+ }
+ if response.Content == "" {
+ t.Error("Response content is empty")
+ }
+ if response.FinishReason == "" {
+ t.Error("FinishReason is empty")
+ }
+
+ // DeepSeek R1 should have reasoning content
+ if response.ReasoningContent == "" {
+ t.Error("Expected reasoning_content but got empty")
+ } else {
+ t.Logf("Reasoning content length: %d characters", len(response.ReasoningContent))
+ }
+
+ // Check reasoning tokens in usage
+ if response.Usage == nil {
+ t.Error("Response Usage is nil")
+ } else {
+ if response.Usage.TotalTokens == 0 {
+ t.Error("Response Usage.TotalTokens is 0")
+ }
+ if response.Usage.CompletionTokensDetails != nil {
+ if response.Usage.CompletionTokensDetails.ReasoningTokens == 0 {
+ t.Error("Expected reasoning_tokens > 0 for DeepSeek R1")
+ } else {
+ t.Logf("Reasoning tokens: %d", response.Usage.CompletionTokensDetails.ReasoningTokens)
+ }
+ }
+ t.Logf("Usage: prompt=%d, completion=%d, total=%d",
+ response.Usage.PromptTokens, response.Usage.CompletionTokens, response.Usage.TotalTokens)
+ }
+
+ // Should have received reasoning chunks
+ if len(reasoningChunks) == 0 {
+ t.Error("Expected reasoning chunks (ChunkThinking) but got none")
+ } else {
+ t.Logf("Received %d reasoning chunks", len(reasoningChunks))
+ }
+
+ // Should have received content chunks
+ if len(contentChunks) == 0 {
+ t.Error("Expected content chunks (ChunkText) but got none")
+ } else {
+ t.Logf("Received %d content chunks", len(contentChunks))
+ }
+
+ t.Logf("Final response: %+v", response)
+}
+
+// TestDeepSeekR1PostBasic tests basic non-streaming completion
+func TestDeepSeekR1PostBasic(t *testing.T) {
+ test.Prepare(t, config.Conf)
+ defer test.Clean()
+
+ // Create connector
+ conn, err := connector.Select("deepseek.r1")
+ if err != nil {
+ t.Fatalf("Failed to select connector: %v", err)
+ }
+
+ // Create LLM instance
+ trueVal := true
+ falseVal := false
+ options := &context.CompletionOptions{
+ Capabilities: &context.ModelCapabilities{
+ Reasoning: &trueVal,
+ ToolCalls: &falseVal,
+ Vision: &falseVal,
+ Audio: &falseVal,
+ Multimodal: &falseVal,
+ },
+ }
+
+ llmInstance, err := llm.New(conn, options)
+ if err != nil {
+ t.Fatalf("Failed to create LLM instance: %v", err)
+ }
+
+ // Prepare messages (very simple question)
+ messages := []context.Message{
+ {
+ Role: context.RoleUser,
+ Content: "What is 1+1?",
+ },
+ }
+
+ // Set max tokens (enough for reasoning + answer)
+ maxTokens := 500
+ options.MaxTokens = &maxTokens
+
+ // Create context
+ ctx := newDeepSeekTestContext("test-deepseek-r1-post", "deepseek.r1")
+
+ // Call Post
+ response, err := llmInstance.Post(ctx, messages, options)
+ if err != nil {
+ t.Fatalf("Post failed: %v", err)
+ }
+
+ // Validate response
+ if response == nil {
+ t.Fatal("Response is nil")
+ }
+
+ if response.ID == "" {
+ t.Error("Response ID is empty")
+ }
+ if response.Model == "" {
+ t.Error("Response Model is empty")
+ }
+ if response.Content == "" {
+ t.Error("Response content is empty")
+ }
+
+ // DeepSeek R1 should have reasoning content
+ if response.ReasoningContent == "" {
+ t.Error("Expected reasoning_content but got empty")
+ } else {
+ t.Logf("Reasoning content: %s", response.ReasoningContent)
+ t.Logf("Final answer: %s", response.Content)
+ }
+
+ // Check usage
+ if response.Usage == nil {
+ t.Error("Response Usage is nil")
+ } else {
+ if response.Usage.TotalTokens == 0 {
+ t.Error("Response Usage.TotalTokens is 0")
+ }
+ if response.Usage.CompletionTokensDetails != nil && response.Usage.CompletionTokensDetails.ReasoningTokens > 0 {
+ t.Logf("Reasoning tokens: %d", response.Usage.CompletionTokensDetails.ReasoningTokens)
+ }
+ t.Logf("Usage: prompt=%d, completion=%d, total=%d",
+ response.Usage.PromptTokens, response.Usage.CompletionTokens, response.Usage.TotalTokens)
+ }
+
+ t.Logf("Response: %+v", response)
+}
+
+// TestDeepSeekR1LogicPuzzle tests DeepSeek R1's reasoning with a logic puzzle
+func TestDeepSeekR1LogicPuzzle(t *testing.T) {
+ test.Prepare(t, config.Conf)
+ defer test.Clean()
+
+ conn, err := connector.Select("deepseek.r1")
+ if err != nil {
+ t.Fatalf("Failed to select connector: %v", err)
+ }
+
+ trueVal := true
+ falseVal := false
+ options := &context.CompletionOptions{
+ Capabilities: &context.ModelCapabilities{
+ Streaming: &trueVal,
+ Reasoning: &trueVal,
+ ToolCalls: &falseVal,
+ Vision: &falseVal,
+ Audio: &falseVal,
+ Multimodal: &falseVal,
+ },
+ }
+
+ maxTokens := 800
+ options.MaxTokens = &maxTokens
+
+ llmInstance, err := llm.New(conn, options)
+ if err != nil {
+ t.Fatalf("Failed to create LLM instance: %v", err)
+ }
+
+ // Use a simpler logic question
+ messages := []context.Message{
+ {
+ Role: context.RoleUser,
+ Content: "Is 5 greater than 3? Explain your reasoning.",
+ },
+ }
+
+ ctx := newDeepSeekTestContext("test-deepseek-r1-logic", "deepseek.r1")
+
+ // Track reasoning and content separately
+ var hasReasoning, hasContent bool
+ handler := func(chunkType context.StreamChunkType, data []byte) int {
+ if chunkType == context.ChunkThinking && len(data) > 0 {
+ hasReasoning = true
+ } else if chunkType == context.ChunkText && len(data) > 0 {
+ hasContent = true
+ }
+ return 0
+ }
+
+ response, err := llmInstance.Stream(ctx, messages, options, handler)
+ if err != nil {
+ t.Fatalf("Stream failed: %v", err)
+ }
+
+ if response == nil {
+ t.Fatal("Response is nil")
+ }
+
+ // Should have both reasoning and content
+ if !hasReasoning {
+ t.Error("Expected to receive reasoning chunks but didn't")
+ }
+ if !hasContent {
+ t.Error("Expected to receive content chunks but didn't")
+ }
+
+ // Validate reasoning content exists and is substantial
+ if response.ReasoningContent == "" {
+ t.Error("Expected reasoning_content but got empty")
+ } else if len(response.ReasoningContent) < 50 {
+ t.Errorf("Reasoning content too short (%d chars), expected detailed thinking", len(response.ReasoningContent))
+ } else {
+ t.Logf("✓ Reasoning content length: %d characters", len(response.ReasoningContent))
+ }
+
+ // Validate final answer
+ contentStr := ""
+ if response.Content != nil {
+ if str, ok := response.Content.(string); ok {
+ contentStr = str
+ }
+ }
+
+ if len(contentStr) == 0 {
+ t.Error("Content is empty")
+ } else {
+ // Should mention "Yes" or affirm that 5 > 3
+ if !strings.Contains(strings.ToLower(contentStr), "yes") && !strings.Contains(strings.ToLower(contentStr), "greater") {
+ t.Logf("Warning: Content might not contain expected answer. Content: %s", contentStr)
+ } else {
+ t.Logf("✓ Final answer: %s", contentStr)
+ }
+ }
+
+ // Check reasoning tokens
+ if response.Usage != nil && response.Usage.CompletionTokensDetails != nil {
+ reasoningTokens := response.Usage.CompletionTokensDetails.ReasoningTokens
+ if reasoningTokens == 0 {
+ t.Error("Expected reasoning_tokens > 0")
+ } else {
+ t.Logf("✓ Reasoning tokens: %d", reasoningTokens)
+ }
+ }
+
+ t.Log("Logic puzzle test completed successfully")
+}
+
+// ============================================================================
+// Helper Functions
+// ============================================================================
+
+// newDeepSeekTestContext creates a real Context for testing DeepSeek provider
+func newDeepSeekTestContext(chatID, connectorID string) *context.Context {
+ return &context.Context{
+ Context: gocontext.Background(),
+ Space: plan.NewMemorySharedSpace(),
+ ChatID: chatID,
+ AssistantID: "test-assistant",
+ Connector: connectorID,
+ Locale: "en-us",
+ Theme: "light",
+ Client: context.Client{
+ Type: "web",
+ UserAgent: "DeepSeekProviderTest/1.0",
+ IP: "127.0.0.1",
+ },
+ Referer: context.RefererAPI,
+ Accept: context.AcceptStandard,
+ Route: "/api/test",
+ Metadata: make(map[string]interface{}),
+ Authorized: &types.AuthorizedInfo{
+ Subject: "test-user",
+ ClientID: "test-client",
+ UserID: "test-user-123",
+ TeamID: "test-team-456",
+ TenantID: "test-tenant-789",
+ SessionID: "test-session-id",
+ Constraints: types.DataConstraints{
+ TeamOnly: true,
+ Extra: map[string]interface{}{
+ "test": "deepseek-provider",
+ },
+ },
+ },
+ }
+}
diff --git a/agent/llm/providers/openai/deepseek_v3_test.go b/agent/llm/providers/openai/deepseek_v3_test.go
new file mode 100644
index 00000000..9ee3b27e
--- /dev/null
+++ b/agent/llm/providers/openai/deepseek_v3_test.go
@@ -0,0 +1,405 @@
+package openai_test
+
+import (
+ gocontext "context"
+ "testing"
+
+ "github.com/yaoapp/gou/connector"
+ "github.com/yaoapp/gou/plan"
+ "github.com/yaoapp/yao/agent/context"
+ "github.com/yaoapp/yao/agent/llm"
+ "github.com/yaoapp/yao/config"
+ "github.com/yaoapp/yao/openapi/oauth/types"
+ "github.com/yaoapp/yao/test"
+)
+
+// TestDeepSeekV3StreamBasic tests basic streaming completion with DeepSeek V3
+func TestDeepSeekV3StreamBasic(t *testing.T) {
+ test.Prepare(t, config.Conf)
+ defer test.Clean()
+
+ conn, err := connector.Select("deepseek.v3")
+ if err != nil {
+ t.Fatalf("Failed to select connector: %v", err)
+ }
+
+ trueVal := true
+ falseVal := false
+ options := &context.CompletionOptions{
+ Capabilities: &context.ModelCapabilities{
+ Streaming: &trueVal,
+ Reasoning: &falseVal, // V3 doesn't support reasoning
+ ToolCalls: &trueVal, // V3 supports tool calls
+ Vision: &falseVal,
+ Audio: &falseVal,
+ Multimodal: &falseVal,
+ },
+ }
+
+ llmInstance, err := llm.New(conn, options)
+ if err != nil {
+ t.Fatalf("Failed to create LLM instance: %v", err)
+ }
+
+ // Simple math question
+ messages := []context.Message{
+ {
+ Role: context.RoleUser,
+ Content: "What is 5 + 3?",
+ },
+ }
+
+ // Set max tokens
+ maxTokens := 100
+ options.MaxTokens = &maxTokens
+
+ ctx := newDeepSeekV3TestContext("test-deepseek-v3-basic", "deepseek.v3")
+
+ // Track streaming chunks
+ var contentChunks []string
+ handler := func(chunkType context.StreamChunkType, data []byte) int {
+ dataStr := string(data)
+ t.Logf("Stream chunk [%s]: %s", chunkType, dataStr)
+
+ if chunkType == context.ChunkText {
+ contentChunks = append(contentChunks, dataStr)
+ }
+
+ return 0 // Continue
+ }
+
+ // Call Stream
+ response, err := llmInstance.Stream(ctx, messages, options, handler)
+ if err != nil {
+ t.Fatalf("Stream failed: %v", err)
+ }
+
+ // Validate response
+ if response == nil {
+ t.Fatal("Response is nil")
+ }
+
+ if response.ID == "" {
+ t.Error("Response ID is empty")
+ }
+ if response.Model == "" {
+ t.Error("Response Model is empty")
+ }
+
+ // Should have content (V3 is not a reasoning model)
+ contentStr, ok := response.Content.(string)
+ if !ok || contentStr == "" {
+ t.Error("Expected content but got empty")
+ } else {
+ t.Logf("Response content: %s", contentStr)
+ }
+
+ // Should NOT have reasoning content (V3 doesn't support reasoning)
+ if response.ReasoningContent != "" {
+ t.Errorf("Expected no reasoning_content for V3, but got: %s", response.ReasoningContent)
+ }
+
+ // Check usage
+ if response.Usage == nil {
+ t.Error("Response Usage is nil")
+ } else {
+ t.Logf("Usage: prompt=%d, completion=%d, total=%d",
+ response.Usage.PromptTokens, response.Usage.CompletionTokens, response.Usage.TotalTokens)
+
+ // Should have 0 reasoning tokens
+ if response.Usage.CompletionTokensDetails != nil {
+ reasoningTokens := response.Usage.CompletionTokensDetails.ReasoningTokens
+ if reasoningTokens != 0 {
+ t.Errorf("Expected reasoning_tokens=0 for V3, got %d", reasoningTokens)
+ }
+ }
+ }
+
+ if len(contentChunks) == 0 {
+ t.Error("Expected content chunks but got none")
+ } else {
+ t.Logf("Received %d content chunks", len(contentChunks))
+ }
+
+ t.Logf("Final response: %+v", response)
+}
+
+// TestDeepSeekV3PostBasic tests basic non-streaming completion
+func TestDeepSeekV3PostBasic(t *testing.T) {
+ test.Prepare(t, config.Conf)
+ defer test.Clean()
+
+ conn, err := connector.Select("deepseek.v3")
+ if err != nil {
+ t.Fatalf("Failed to select connector: %v", err)
+ }
+
+ trueVal := true
+ falseVal := false
+ options := &context.CompletionOptions{
+ Capabilities: &context.ModelCapabilities{
+ Reasoning: &falseVal,
+ ToolCalls: &trueVal,
+ Vision: &falseVal,
+ Audio: &falseVal,
+ Multimodal: &falseVal,
+ },
+ }
+
+ llmInstance, err := llm.New(conn, options)
+ if err != nil {
+ t.Fatalf("Failed to create LLM instance: %v", err)
+ }
+
+ // Simple question
+ messages := []context.Message{
+ {
+ Role: context.RoleUser,
+ Content: "What is 2 * 4?",
+ },
+ }
+
+ // Set max tokens
+ maxTokens := 100
+ options.MaxTokens = &maxTokens
+
+ ctx := newDeepSeekV3TestContext("test-deepseek-v3-post", "deepseek.v3")
+
+ // Call Post
+ response, err := llmInstance.Post(ctx, messages, options)
+ if err != nil {
+ t.Fatalf("Post failed: %v", err)
+ }
+
+ // Validate response
+ if response == nil {
+ t.Fatal("Response is nil")
+ }
+
+ if response.ID == "" {
+ t.Error("Response ID is empty")
+ }
+ if response.Model == "" {
+ t.Error("Response Model is empty")
+ }
+
+ // Should have content
+ contentStr, ok := response.Content.(string)
+ if !ok || contentStr == "" {
+ t.Error("Expected content but got empty")
+ } else {
+ t.Logf("Response content: %s", contentStr)
+ }
+
+ // Should NOT have reasoning content
+ if response.ReasoningContent != "" {
+ t.Errorf("V3 should not have reasoning_content, but got: %s", response.ReasoningContent)
+ }
+
+ // Check usage
+ if response.Usage == nil {
+ t.Error("Response Usage is nil")
+ } else {
+ t.Logf("Usage: prompt=%d, completion=%d, total=%d",
+ response.Usage.PromptTokens, response.Usage.CompletionTokens, response.Usage.TotalTokens)
+
+ // Should have 0 reasoning tokens
+ if response.Usage.CompletionTokensDetails != nil {
+ reasoningTokens := response.Usage.CompletionTokensDetails.ReasoningTokens
+ if reasoningTokens != 0 {
+ t.Errorf("Expected reasoning_tokens=0 for V3, got %d", reasoningTokens)
+ }
+ }
+ }
+
+ t.Logf("Response: %+v", response)
+}
+
+// TestDeepSeekV3WithToolCalls tests V3 with tool calls
+func TestDeepSeekV3WithToolCalls(t *testing.T) {
+ test.Prepare(t, config.Conf)
+ defer test.Clean()
+
+ conn, err := connector.Select("deepseek.v3")
+ if err != nil {
+ t.Fatalf("Failed to select connector: %v", err)
+ }
+
+ trueVal := true
+ falseVal := false
+ options := &context.CompletionOptions{
+ Capabilities: &context.ModelCapabilities{
+ Reasoning: &falseVal,
+ ToolCalls: &trueVal,
+ },
+ }
+
+ // Define a weather tool
+ weatherTool := map[string]interface{}{
+ "type": "function",
+ "function": map[string]interface{}{
+ "name": "get_weather",
+ "description": "Get current weather for a location",
+ "parameters": map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{
+ "location": map[string]interface{}{
+ "type": "string",
+ "description": "City name",
+ },
+ "unit": map[string]interface{}{
+ "type": "string",
+ "enum": []string{"celsius", "fahrenheit"},
+ },
+ },
+ "required": []string{"location"},
+ },
+ },
+ }
+
+ options.Tools = []map[string]interface{}{weatherTool}
+ options.ToolChoice = "auto"
+
+ llmInstance, err := llm.New(conn, options)
+ if err != nil {
+ t.Fatalf("Failed to create LLM instance: %v", err)
+ }
+
+ messages := []context.Message{
+ {
+ Role: context.RoleUser,
+ Content: "What's the weather in Beijing?",
+ },
+ }
+
+ ctx := newDeepSeekV3TestContext("test-deepseek-v3-tools", "deepseek.v3")
+
+ response, err := llmInstance.Post(ctx, messages, options)
+ if err != nil {
+ t.Fatalf("Post with tool calls failed: %v", err)
+ }
+
+ if response == nil {
+ t.Fatal("Response is nil")
+ }
+
+ // Should have tool calls
+ if len(response.ToolCalls) == 0 {
+ t.Error("Expected tool calls but got none")
+ } else {
+ tc := response.ToolCalls[0]
+ t.Logf("✓ Tool call: %s(%s)", tc.Function.Name, tc.Function.Arguments)
+
+ if tc.Function.Name != "get_weather" {
+ t.Errorf("Expected tool name 'get_weather', got '%s'", tc.Function.Name)
+ }
+ }
+
+ if response.Usage != nil {
+ t.Logf("Usage: prompt=%d, completion=%d, total=%d",
+ response.Usage.PromptTokens, response.Usage.CompletionTokens, response.Usage.TotalTokens)
+ }
+
+ t.Logf("Response: %+v", response)
+}
+
+// TestDeepSeekV3NoReasoningEffort tests that V3 ignores reasoning_effort parameter
+func TestDeepSeekV3NoReasoningEffort(t *testing.T) {
+ test.Prepare(t, config.Conf)
+ defer test.Clean()
+
+ conn, err := connector.Select("deepseek.v3")
+ if err != nil {
+ t.Fatalf("Failed to select connector: %v", err)
+ }
+
+ trueVal := true
+ falseVal := false
+ effort := "high"
+ options := &context.CompletionOptions{
+ Capabilities: &context.ModelCapabilities{
+ Reasoning: &falseVal, // V3 doesn't support reasoning
+ ToolCalls: &trueVal,
+ },
+ ReasoningEffort: &effort, // Should be ignored by adapter
+ }
+
+ llmInstance, err := llm.New(conn, options)
+ if err != nil {
+ t.Fatalf("Failed to create LLM instance: %v", err)
+ }
+
+ messages := []context.Message{
+ {
+ Role: context.RoleUser,
+ Content: "Say 'OK'",
+ },
+ }
+
+ maxTokens := 10
+ options.MaxTokens = &maxTokens
+
+ ctx := newDeepSeekV3TestContext("test-deepseek-v3-no-reasoning", "deepseek.v3")
+
+ // Should succeed (adapter removes reasoning_effort parameter)
+ response, err := llmInstance.Post(ctx, messages, options)
+ if err != nil {
+ t.Fatalf("Post failed: %v", err)
+ }
+
+ if response == nil {
+ t.Fatal("Response is nil")
+ }
+
+ // Should have 0 reasoning tokens
+ if response.Usage != nil && response.Usage.CompletionTokensDetails != nil {
+ reasoningTokens := response.Usage.CompletionTokensDetails.ReasoningTokens
+ if reasoningTokens != 0 {
+ t.Errorf("Expected reasoning_tokens=0 for V3, got %d", reasoningTokens)
+ } else {
+ t.Log("✓ V3 correctly shows reasoning_tokens=0")
+ }
+ }
+
+ t.Log("✓ ReasoningAdapter correctly removed reasoning_effort parameter for V3")
+}
+
+// ============================================================================
+// Helper Functions
+// ============================================================================
+
+// newDeepSeekV3TestContext creates a real Context for testing DeepSeek V3 provider
+func newDeepSeekV3TestContext(chatID, connectorID string) *context.Context {
+ return &context.Context{
+ Context: gocontext.Background(),
+ Space: plan.NewMemorySharedSpace(),
+ ChatID: chatID,
+ AssistantID: "test-assistant",
+ Connector: connectorID,
+ Locale: "en-us",
+ Theme: "light",
+ Client: context.Client{
+ Type: "web",
+ UserAgent: "DeepSeekV3ProviderTest/1.0",
+ IP: "127.0.0.1",
+ },
+ Referer: context.RefererAPI,
+ Accept: context.AcceptStandard,
+ Route: "/api/test",
+ Metadata: make(map[string]interface{}),
+ Authorized: &types.AuthorizedInfo{
+ Subject: "test-user",
+ ClientID: "test-client",
+ UserID: "test-user-123",
+ TeamID: "test-team-456",
+ TenantID: "test-tenant-789",
+ SessionID: "test-session-id",
+ Constraints: types.DataConstraints{
+ TeamOnly: true,
+ Extra: map[string]interface{}{
+ "test": "deepseek-v3-provider",
+ },
+ },
+ },
+ }
+}
diff --git a/agent/llm/providers/openai/gpt5_test.go b/agent/llm/providers/openai/gpt5_test.go
new file mode 100644
index 00000000..ff0f55e5
--- /dev/null
+++ b/agent/llm/providers/openai/gpt5_test.go
@@ -0,0 +1,422 @@
+package openai_test
+
+import (
+ gocontext "context"
+ "testing"
+
+ "github.com/yaoapp/gou/connector"
+ "github.com/yaoapp/gou/plan"
+ "github.com/yaoapp/yao/agent/context"
+ "github.com/yaoapp/yao/agent/llm"
+ "github.com/yaoapp/yao/config"
+ "github.com/yaoapp/yao/openapi/oauth/types"
+ "github.com/yaoapp/yao/test"
+)
+
+// TestGPT5StreamBasic tests basic streaming completion with GPT-5
+func TestGPT5StreamBasic(t *testing.T) {
+ test.Prepare(t, config.Conf)
+ defer test.Clean()
+
+ conn, err := connector.Select("openai.gpt-5")
+ if err != nil {
+ t.Fatalf("Failed to select connector: %v", err)
+ }
+
+ trueVal := true
+ options := &context.CompletionOptions{
+ Capabilities: &context.ModelCapabilities{
+ Streaming: &trueVal,
+ Reasoning: &trueVal, // GPT-5 supports reasoning
+ ToolCalls: &trueVal,
+ Vision: &trueVal,
+ Multimodal: &trueVal,
+ },
+ }
+
+ llmInstance, err := llm.New(conn, options)
+ if err != nil {
+ t.Fatalf("Failed to create LLM instance: %v", err)
+ }
+
+ messages := []context.Message{
+ {
+ Role: context.RoleUser,
+ Content: "What is 1+1? Reply with just the number.",
+ },
+ }
+
+ maxTokens := 100
+ options.MaxCompletionTokens = &maxTokens
+
+ ctx := newGPT5TestContext("test-gpt5-basic", "openai.gpt-5")
+
+ var chunks []string
+ handler := func(chunkType context.StreamChunkType, data []byte) int {
+ chunks = append(chunks, string(data))
+ t.Logf("Stream chunk [%s]: %s", chunkType, string(data))
+ return 0
+ }
+
+ response, err := llmInstance.Stream(ctx, messages, options, handler)
+ if err != nil {
+ t.Fatalf("Stream failed: %v", err)
+ }
+
+ if response == nil {
+ t.Fatal("Response is nil")
+ }
+
+ // Basic validation
+ if response.ID == "" {
+ t.Error("Response ID is empty")
+ }
+ if response.Model == "" {
+ t.Error("Response Model is empty")
+ }
+
+ // GPT-5 may use all tokens for reasoning, so content could be empty
+ // Just log the content instead of failing
+ t.Logf("Response content: %v", response.Content)
+ t.Logf("Usage: prompt=%d, completion=%d, total=%d",
+ response.Usage.PromptTokens, response.Usage.CompletionTokens, response.Usage.TotalTokens)
+
+ if response.Usage != nil && response.Usage.CompletionTokensDetails != nil {
+ t.Logf("Reasoning tokens: %d", response.Usage.CompletionTokensDetails.ReasoningTokens)
+ }
+
+ t.Logf("Final response: %+v", response)
+ t.Logf("Total chunks received: %d", len(chunks))
+}
+
+// TestGPT5ReasoningEffort tests reasoning_effort parameter with different levels
+func TestGPT5ReasoningEffort(t *testing.T) {
+ test.Prepare(t, config.Conf)
+ defer test.Clean()
+
+ conn, err := connector.Select("openai.gpt-5")
+ if err != nil {
+ t.Fatalf("Failed to select connector: %v", err)
+ }
+
+ // Test with different reasoning effort levels
+ effortLevels := []string{"low", "medium", "high"}
+
+ for _, effort := range effortLevels {
+ t.Run("effort_"+effort, func(t *testing.T) {
+ trueVal := true
+ options := &context.CompletionOptions{
+ Capabilities: &context.ModelCapabilities{
+ Reasoning: &trueVal,
+ ToolCalls: &trueVal,
+ },
+ ReasoningEffort: &effort,
+ }
+
+ llmInstance, err := llm.New(conn, options)
+ if err != nil {
+ t.Fatalf("Failed to create LLM instance: %v", err)
+ }
+
+ messages := []context.Message{
+ {
+ Role: context.RoleUser,
+ Content: "Solve: If all Bloops are Razzies and all Razzies are Lazzies, are all Bloops Lazzies?",
+ },
+ }
+
+ maxTokens := 1000
+ options.MaxCompletionTokens = &maxTokens
+
+ ctx := newGPT5TestContext("test-gpt5-reasoning-"+effort, "openai.gpt-5")
+
+ response, err := llmInstance.Post(ctx, messages, options)
+ if err != nil {
+ t.Fatalf("Post failed with effort=%s: %v", effort, err)
+ }
+
+ if response == nil {
+ t.Fatal("Response is nil")
+ }
+
+ // Check reasoning tokens
+ var reasoningTokens int
+ if response.Usage != nil && response.Usage.CompletionTokensDetails != nil {
+ reasoningTokens = response.Usage.CompletionTokensDetails.ReasoningTokens
+ }
+
+ t.Logf("Reasoning effort: %s", effort)
+ t.Logf("Reasoning tokens: %d", reasoningTokens)
+ t.Logf("Total tokens: %d", response.Usage.TotalTokens)
+ t.Logf("Content: %s", response.Content)
+
+ // GPT-5 reasoning is hidden (no reasoning_content field)
+ // But should have reasoning_tokens in usage
+ if effort != "low" {
+ if reasoningTokens == 0 {
+ t.Logf("Warning: Expected reasoning_tokens > 0 for effort='%s', got 0", effort)
+ }
+ }
+ })
+ }
+}
+
+// TestGPT5PostWithToolCalls tests GPT-5 with tool calls
+func TestGPT5PostWithToolCalls(t *testing.T) {
+ test.Prepare(t, config.Conf)
+ defer test.Clean()
+
+ conn, err := connector.Select("openai.gpt-5")
+ if err != nil {
+ t.Fatalf("Failed to select connector: %v", err)
+ }
+
+ trueVal := true
+ options := &context.CompletionOptions{
+ Capabilities: &context.ModelCapabilities{
+ Reasoning: &trueVal,
+ ToolCalls: &trueVal,
+ },
+ }
+
+ // Define a calculation tool
+ calcTool := map[string]interface{}{
+ "type": "function",
+ "function": map[string]interface{}{
+ "name": "calculate",
+ "description": "Perform a mathematical calculation",
+ "parameters": map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{
+ "expression": map[string]interface{}{
+ "type": "string",
+ "description": "The mathematical expression to evaluate",
+ },
+ },
+ "required": []string{"expression"},
+ },
+ },
+ }
+
+ options.Tools = []map[string]interface{}{calcTool}
+ options.ToolChoice = "auto"
+
+ llmInstance, err := llm.New(conn, options)
+ if err != nil {
+ t.Fatalf("Failed to create LLM instance: %v", err)
+ }
+
+ messages := []context.Message{
+ {
+ Role: context.RoleUser,
+ Content: "Use the calculate function to compute 2 * 3",
+ },
+ }
+
+ ctx := newGPT5TestContext("test-gpt5-tools", "openai.gpt-5")
+
+ response, err := llmInstance.Post(ctx, messages, options)
+ if err != nil {
+ t.Fatalf("Post with tool calls failed: %v", err)
+ }
+
+ if response == nil {
+ t.Fatal("Response is nil")
+ }
+
+ // GPT-5 reasoning models may not always use tool calls
+ // Log what we got instead of failing
+ if len(response.ToolCalls) == 0 {
+ t.Logf("No tool calls returned. Content: %v", response.Content)
+ } else {
+ tc := response.ToolCalls[0]
+ t.Logf("✓ Tool call: %s(%s)", tc.Function.Name, tc.Function.Arguments)
+
+ if tc.Function.Name != "calculate" {
+ t.Logf("Warning: Expected tool name 'calculate', got '%s'", tc.Function.Name)
+ }
+ }
+
+ if response.Usage != nil {
+ t.Logf("Usage: prompt=%d, completion=%d, total=%d",
+ response.Usage.PromptTokens, response.Usage.CompletionTokens, response.Usage.TotalTokens)
+ if response.Usage.CompletionTokensDetails != nil {
+ t.Logf("Reasoning tokens: %d", response.Usage.CompletionTokensDetails.ReasoningTokens)
+ }
+ }
+
+ t.Logf("Response: %+v", response)
+}
+
+// TestGPT5Vision tests GPT-5 with image input
+func TestGPT5Vision(t *testing.T) {
+ test.Prepare(t, config.Conf)
+ defer test.Clean()
+
+ conn, err := connector.Select("openai.gpt-5")
+ if err != nil {
+ t.Fatalf("Failed to select connector: %v", err)
+ }
+
+ trueVal := true
+ options := &context.CompletionOptions{
+ Capabilities: &context.ModelCapabilities{
+ Reasoning: &trueVal,
+ Vision: &trueVal,
+ Multimodal: &trueVal,
+ },
+ }
+
+ llmInstance, err := llm.New(conn, options)
+ if err != nil {
+ t.Fatalf("Failed to create LLM instance: %v", err)
+ }
+
+ // Message with image content
+ messages := []context.Message{
+ {
+ Role: context.RoleUser,
+ Content: []context.ContentPart{
+ {
+ Type: context.ContentText,
+ Text: "What is in this image? Describe briefly.",
+ },
+ {
+ Type: context.ContentImageURL,
+ ImageURL: &context.ImageURL{
+ URL: "https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/320px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg",
+ },
+ },
+ },
+ },
+ }
+
+ maxTokens := 200
+ options.MaxCompletionTokens = &maxTokens
+
+ ctx := newGPT5TestContext("test-gpt5-vision", "openai.gpt-5")
+
+ response, err := llmInstance.Post(ctx, messages, options)
+ if err != nil {
+ t.Fatalf("Post with vision failed: %v", err)
+ }
+
+ if response == nil {
+ t.Fatal("Response is nil")
+ }
+
+ // Should have content describing the image
+ contentStr, ok := response.Content.(string)
+ if !ok || contentStr == "" {
+ t.Error("Expected text content describing the image")
+ } else {
+ t.Logf("Image description: %s", contentStr)
+ }
+
+ if response.Usage != nil {
+ t.Logf("Usage: prompt=%d, completion=%d, total=%d",
+ response.Usage.PromptTokens, response.Usage.CompletionTokens, response.Usage.TotalTokens)
+ }
+}
+
+// TestGPT5ReasoningEffortWithGPT4o tests that GPT-4o ignores reasoning_effort
+func TestGPT5ReasoningEffortWithGPT4o(t *testing.T) {
+ test.Prepare(t, config.Conf)
+ defer test.Clean()
+
+ // Use GPT-4o which doesn't support reasoning
+ conn, err := connector.Select("openai.gpt-4o")
+ if err != nil {
+ t.Fatalf("Failed to select connector: %v", err)
+ }
+
+ trueVal := true
+ falseVal := false
+ effort := "high"
+ options := &context.CompletionOptions{
+ Capabilities: &context.ModelCapabilities{
+ Reasoning: &falseVal, // GPT-4o doesn't support reasoning
+ ToolCalls: &trueVal,
+ },
+ ReasoningEffort: &effort, // Should be ignored by adapter
+ }
+
+ llmInstance, err := llm.New(conn, options)
+ if err != nil {
+ t.Fatalf("Failed to create LLM instance: %v", err)
+ }
+
+ messages := []context.Message{
+ {
+ Role: context.RoleUser,
+ Content: "Say 'OK'",
+ },
+ }
+
+ maxTokens := 10
+ options.MaxCompletionTokens = &maxTokens
+
+ ctx := newGPT5TestContext("test-gpt4o-no-reasoning", "openai.gpt-4o")
+
+ // Should succeed (adapter removes reasoning_effort parameter)
+ response, err := llmInstance.Post(ctx, messages, options)
+ if err != nil {
+ t.Fatalf("Post failed: %v", err)
+ }
+
+ if response == nil {
+ t.Fatal("Response is nil")
+ }
+
+ // Should have 0 reasoning tokens (GPT-4o doesn't do reasoning)
+ if response.Usage != nil && response.Usage.CompletionTokensDetails != nil {
+ reasoningTokens := response.Usage.CompletionTokensDetails.ReasoningTokens
+ if reasoningTokens != 0 {
+ t.Errorf("Expected reasoning_tokens=0 for GPT-4o, got %d", reasoningTokens)
+ } else {
+ t.Log("✓ GPT-4o correctly shows reasoning_tokens=0")
+ }
+ }
+
+ t.Log("✓ ReasoningAdapter correctly removed reasoning_effort parameter for GPT-4o")
+}
+
+// ============================================================================
+// Helper Functions
+// ============================================================================
+
+// newGPT5TestContext creates a real Context for testing GPT-5 provider
+func newGPT5TestContext(chatID, connectorID string) *context.Context {
+ return &context.Context{
+ Context: gocontext.Background(),
+ Space: plan.NewMemorySharedSpace(),
+ ChatID: chatID,
+ AssistantID: "test-assistant",
+ Connector: connectorID,
+ Locale: "en-us",
+ Theme: "light",
+ Client: context.Client{
+ Type: "web",
+ UserAgent: "GPT5ProviderTest/1.0",
+ IP: "127.0.0.1",
+ },
+ Referer: context.RefererAPI,
+ Accept: context.AcceptStandard,
+ Route: "/api/test",
+ Metadata: make(map[string]interface{}),
+ Authorized: &types.AuthorizedInfo{
+ Subject: "test-user",
+ ClientID: "test-client",
+ UserID: "test-user-123",
+ TeamID: "test-team-456",
+ TenantID: "test-tenant-789",
+ SessionID: "test-session-id",
+ Constraints: types.DataConstraints{
+ TeamOnly: true,
+ Extra: map[string]interface{}{
+ "test": "gpt5-provider",
+ },
+ },
+ },
+ }
+}
diff --git a/agent/llm/providers/openai/openai.go b/agent/llm/providers/openai/openai.go
index e270d9b1..c0ca5a94 100644
--- a/agent/llm/providers/openai/openai.go
+++ b/agent/llm/providers/openai/openai.go
@@ -141,11 +141,17 @@ func buildAdapters(cap *context.ModelCapabilities) []adapters.CapabilityAdapter
result = append(result, adapters.NewAudioAdapter(*cap.Audio))
}
- // Reasoning adapter
- if cap.Reasoning != nil && *cap.Reasoning {
- // Detect reasoning format based on capabilities
- format := detectReasoningFormat(cap)
- result = append(result, adapters.NewReasoningAdapter(format))
+ // Reasoning adapter (always add to handle reasoning_effort parameter)
+ // Even if the model doesn't support reasoning, we need the adapter to strip reasoning_effort
+ if cap.Reasoning != nil {
+ if *cap.Reasoning {
+ // Detect reasoning format based on capabilities
+ format := detectReasoningFormat(cap)
+ result = append(result, adapters.NewReasoningAdapter(format))
+ } else {
+ // Model doesn't support reasoning, use None format to strip reasoning parameters
+ result = append(result, adapters.NewReasoningAdapter(adapters.ReasoningFormatNone))
+ }
}
return result
@@ -282,8 +288,22 @@ func (p *Provider) streamWithRetry(ctx *context.Context, messages []context.Mess
}
}
+ // Preprocess options through adapters
+ processedOptions := options
+ for _, adapter := range p.adapters {
+ newOpts, err := adapter.PreprocessOptions(processedOptions)
+ if err != nil {
+ // Send error to handler
+ if handler != nil {
+ handler(context.ChunkError, []byte(fmt.Sprintf("adapter %s preprocessing failed: %v", adapter.Name(), err)))
+ }
+ return nil, fmt.Errorf("adapter %s preprocessing failed: %w", adapter.Name(), err)
+ }
+ processedOptions = newOpts
+ }
+
// Build request body
- requestBody, err := p.buildRequestBody(messages, options, true)
+ requestBody, err := p.buildRequestBody(messages, processedOptions, true)
if err != nil {
// Send stream_end with error
if handler != nil {
@@ -391,6 +411,20 @@ func (p *Provider) streamWithRetry(ctx *context.Context, messages []context.Mess
accumulator.role = delta.Role
}
+ // Handle reasoning content (DeepSeek R1)
+ if delta.ReasoningContent != "" {
+ // Start thinking group if not active
+ if !groupTracker.active || groupTracker.groupType != context.ChunkThinking {
+ groupTracker.startGroup(context.ChunkThinking, handler)
+ }
+
+ accumulator.reasoningContent += delta.ReasoningContent
+ if handler != nil {
+ handler(context.ChunkThinking, []byte(delta.ReasoningContent))
+ groupTracker.incrementChunk()
+ }
+ }
+
// Handle content
if delta.Content != "" {
// Start text group if not active
@@ -637,15 +671,16 @@ func (p *Provider) streamWithRetry(ctx *context.Context, messages []context.Mess
// Build final response
response := &context.CompletionResponse{
- ID: accumulator.id,
- Object: "chat.completion",
- Created: accumulator.created,
- Model: accumulator.model,
- Role: accumulator.role,
- Content: accumulator.content,
- Refusal: accumulator.refusal,
- FinishReason: accumulator.finishReason,
- Usage: accumulator.usage,
+ ID: accumulator.id,
+ Object: "chat.completion",
+ Created: accumulator.created,
+ Model: accumulator.model,
+ Role: accumulator.role,
+ Content: accumulator.content,
+ ReasoningContent: accumulator.reasoningContent,
+ Refusal: accumulator.refusal,
+ FinishReason: accumulator.finishReason,
+ Usage: accumulator.usage,
}
// Convert accumulated tool calls to ToolCall slice
@@ -801,8 +836,18 @@ func (p *Provider) Post(ctx *context.Context, messages []context.Message, option
// postWithRetry performs a single POST request attempt
func (p *Provider) postWithRetry(ctx *context.Context, messages []context.Message, options *context.CompletionOptions) (*context.CompletionResponse, error) {
+ // Preprocess options through adapters
+ processedOptions := options
+ for _, adapter := range p.adapters {
+ newOpts, err := adapter.PreprocessOptions(processedOptions)
+ if err != nil {
+ return nil, fmt.Errorf("adapter %s preprocessing failed: %w", adapter.Name(), err)
+ }
+ processedOptions = newOpts
+ }
+
// Build request body
- requestBody, err := p.buildRequestBody(messages, options, false)
+ requestBody, err := p.buildRequestBody(messages, processedOptions, false)
if err != nil {
return nil, fmt.Errorf("failed to build request body: %w", err)
}
@@ -854,13 +899,29 @@ func (p *Provider) postWithRetry(ctx *context.Context, messages []context.Messag
}
choice := fullResp.Choices[0]
+
+ // Convert content interface{} to string
+ content := ""
+ if choice.Message.Content != nil {
+ switch v := choice.Message.Content.(type) {
+ case string:
+ content = v
+ default:
+ // For complex content (arrays), marshal to JSON
+ if contentBytes, err := jsoniter.Marshal(v); err == nil {
+ content = string(contentBytes)
+ }
+ }
+ }
+
response := &context.CompletionResponse{
ID: fullResp.ID,
Object: fullResp.Object,
Created: fullResp.Created,
Model: fullResp.Model,
Role: string(choice.Message.Role),
- Content: choice.Message.Content,
+ Content: content,
+ ReasoningContent: choice.Message.ReasoningContent,
ToolCalls: choice.Message.ToolCalls,
FinishReason: choice.FinishReason,
Usage: fullResp.Usage,
@@ -999,6 +1060,11 @@ func (p *Provider) buildRequestBody(messages []context.Message, options *context
body["tool_choice"] = options.ToolChoice
}
+ // Reasoning effort (o1 and GPT-5 models)
+ if options.ReasoningEffort != nil {
+ body["reasoning_effort"] = *options.ReasoningEffort
+ }
+
// For streaming, include usage info by default
if streaming {
if options.StreamOptions != nil {
diff --git a/agent/llm/providers/openai/types.go b/agent/llm/providers/openai/types.go
index 0cd475a9..03a871a2 100644
--- a/agent/llm/providers/openai/types.go
+++ b/agent/llm/providers/openai/types.go
@@ -25,10 +25,11 @@ type Delta struct {
// DeltaContent represents the content in a delta
type DeltaContent struct {
- Role string `json:"role,omitempty"`
- Content string `json:"content,omitempty"`
- ToolCalls []ToolCallDelta `json:"tool_calls,omitempty"`
- Refusal string `json:"refusal,omitempty"`
+ Role string `json:"role,omitempty"`
+ Content string `json:"content,omitempty"`
+ ReasoningContent string `json:"reasoning_content,omitempty"` // DeepSeek R1 reasoning
+ ToolCalls []ToolCallDelta `json:"tool_calls,omitempty"`
+ Refusal string `json:"refusal,omitempty"`
}
// ToolCallDelta represents a tool call delta in streaming
@@ -52,9 +53,15 @@ type CompletionResponseFull struct {
Created int64 `json:"created"`
Model string `json:"model"`
Choices []struct {
- Index int `json:"index"`
- Message context.Message `json:"message"`
- FinishReason string `json:"finish_reason"`
+ Index int `json:"index"`
+ Message struct {
+ Role context.MessageRole `json:"role"`
+ Content interface{} `json:"content,omitempty"` // string or array
+ ReasoningContent string `json:"reasoning_content,omitempty"` // DeepSeek R1 reasoning
+ ToolCalls []context.ToolCall `json:"tool_calls,omitempty"`
+ Refusal *string `json:"refusal,omitempty"`
+ } `json:"message"`
+ FinishReason string `json:"finish_reason"`
} `json:"choices"`
Usage *context.UsageInfo `json:"usage,omitempty"`
SystemFingerprint string `json:"system_fingerprint,omitempty"`
@@ -62,15 +69,16 @@ type CompletionResponseFull struct {
// streamAccumulator accumulates streaming response data
type streamAccumulator struct {
- id string
- model string
- created int64
- role string
- content string
- refusal string
- toolCalls map[int]*accumulatedToolCall
- finishReason string
- usage *context.UsageInfo
+ id string
+ model string
+ created int64
+ role string
+ content string
+ reasoningContent string // DeepSeek R1 reasoning content
+ refusal string
+ toolCalls map[int]*accumulatedToolCall
+ finishReason string
+ usage *context.UsageInfo
}
// accumulatedToolCall accumulates a single tool call