diff --git a/.gitattributes b/.gitattributes index 62404f9d5..87c36d570 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ providertests/testdata/**/*.yaml -diff linguist-generated=true +providers/internal/prompttest/testdata/*.json text eol=lf diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 793b4546a..f9529eba2 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,10 +4,7 @@ updates: - package-ecosystem: "gomod" directory: "/" schedule: - interval: "weekly" - day: "monday" - time: "05:00" - timezone: "America/New_York" + interval: "monthly" labels: - "area: dependencies" commit-message: @@ -27,10 +24,7 @@ updates: - package-ecosystem: "gomod" directory: "/examples" schedule: - interval: "weekly" - day: "monday" - time: "05:00" - timezone: "America/New_York" + interval: "monthly" labels: - "area: dependencies" commit-message: @@ -44,10 +38,7 @@ updates: - package-ecosystem: "github-actions" directory: "/" schedule: - interval: "weekly" - day: "monday" - time: "05:00" - timezone: "America/New_York" + interval: "monthly" labels: - "area: dependencies" commit-message: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 35b979c1d..849bb8b7f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,12 +12,11 @@ jobs: GOTOOLCHAIN: local steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - # Keep the module baseline in go.mod, but run the scanner with Go 1.26.6 - # so it uses a fixed standard library (GO-2026-5026, GO-2026-5972, - # GO-2026-6088, GO-2026-6090, GO-2026-6218). + # Keep the module baseline in go.mod, but run the scanner with Go 1.26.10 + # so it scans a patched standard library. - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: "1.26.6" + go-version: "1.26.10" cache: true check-latest: true - run: | diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 52935102d..9b6cef46e 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -21,7 +21,7 @@ jobs: triage: runs-on: ubuntu-latest steps: - - uses: github/issue-labeler@v3.4 + - uses: github/issue-labeler@v3.5 with: configuration-path: .github/labeler.yml enable-versioned-regex: 0 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ed3a1af7b..c0ff86023 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -9,6 +9,6 @@ jobs: uses: charmbracelet/meta/.github/workflows/lint.yml@main with: golangci_path: .golangci.yml - golangci_version: v2.9 + golangci_version: v2.15.0 timeout: 10m os: '["ubuntu-latest"]' diff --git a/Taskfile.yaml b/Taskfile.yaml index 7bc3537fb..462854d84 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -25,6 +25,7 @@ tasks: desc: Run tests for all packages cmds: - go test ./... -count=1 -timeout=30m {{.CLI_ARGS}} + release: desc: Create and push a new tag following semver vars: diff --git a/agent.go b/agent.go index 9626092bf..3c6eb8904 100644 --- a/agent.go +++ b/agent.go @@ -545,6 +545,18 @@ func (a *agent) Generate(ctx context.Context, opts AgentCall) (*AgentResult, err return nil, err } + // Abnormal finishes — length, content filter, provider error, + // unknown — can accompany arguments that were cut short. Skip + // validation and repair entirely (repair may be an extra model + // call), leave the raw tool-call content in the step, and do not + // execute (CHARM-2020). FinishReasonStop with tool calls still + // validates and dispatches: tolerated for providers that report + // stop on a tool turn. + suppressed := result.FinishReason == FinishReasonLength || + result.FinishReason == FinishReasonError || + result.FinishReason == FinishReasonContentFilter || + result.FinishReason == FinishReasonUnknown + var stepToolCalls []ToolCallContent for _, content := range result.Content { if content.GetType() == ContentTypeToolCall { @@ -558,13 +570,27 @@ func (a *agent) Generate(ctx context.Context, opts AgentCall) (*AgentResult, err if toolCall.ProviderExecuted { continue } + if suppressed { + // Keep the raw call for the step record; never repair + // or execute it. + stepToolCalls = append(stepToolCalls, toolCall) + continue + } // Validate and potentially repair the tool call validatedToolCall := a.validateAndRepairToolCall(ctx, toolCall, stepTools, stepExecProviderTools, stepSystemPrompt, stepInputMessages, a.settings.repairToolCall) stepToolCalls = append(stepToolCalls, validatedToolCall) } } - toolResults, err := a.executeTools(ctx, stepTools, stepExecProviderTools, stepToolCalls, nil) + var toolResults []ToolResultContent + if !suppressed { + toolResults = a.executeTools(ctx, stepTools, stepExecProviderTools, stepToolCalls, nil) + } + + // Stop a canceled run rather than looping back to the model. + if err := ctx.Err(); err != nil { + return nil, err + } // If any tool result requested a stop, deliver all results but don't // request another completion from the model. @@ -609,7 +635,7 @@ func (a *agent) Generate(ctx context.Context, opts AgentCall) (*AgentResult, err steps = append(steps, stepResult) shouldStop := isStopConditionMet(opts.StopWhen, steps) - if shouldStop || err != nil || stopTurnRequested || len(stepToolCalls) == 0 || result.FinishReason != FinishReasonToolCalls { + if shouldStop || stopTurnRequested || len(stepToolCalls) == 0 || result.FinishReason != FinishReasonToolCalls { break } } @@ -745,9 +771,9 @@ func toResponseMessages(content []Content) []Message { return messages } -func (a *agent) executeTools(ctx context.Context, allTools []AgentTool, execProviderTools []ExecutableProviderTool, toolCalls []ToolCallContent, toolResultCallback func(result ToolResultContent) error) ([]ToolResultContent, error) { +func (a *agent) executeTools(ctx context.Context, allTools []AgentTool, execProviderTools []ExecutableProviderTool, toolCalls []ToolCallContent, toolResultCallback func(result ToolResultContent) error) []ToolResultContent { if len(toolCalls) == 0 { - return nil, nil + return nil } // Create a map for quick tool lookup @@ -765,35 +791,27 @@ func (a *agent) executeTools(ctx context.Context, allTools []AgentTool, execProv results := make([]ToolResultContent, 0, len(toolCalls)) for _, toolCall := range toolCalls { - result, isCriticalError := a.executeSingleTool(ctx, toolMap, execProviderToolMap, toolCall, toolResultCallback) - results = append(results, result) - if isCriticalError { - if errorResult, ok := result.Result.(ToolResultOutputContentError); ok && errorResult.Error != nil { - return nil, errorResult.Error - } - } + results = append(results, a.executeSingleTool(ctx, toolMap, execProviderToolMap, toolCall, toolResultCallback)) } - return results, nil + return results } -// executeSingleTool executes a single tool and returns its result and a critical error flag. -func (a *agent) executeSingleTool(ctx context.Context, toolMap map[string]AgentTool, execProviderToolMap map[string]ExecutableProviderTool, toolCall ToolCallContent, toolResultCallback func(result ToolResultContent) error) (ToolResultContent, bool) { - result := ToolResultContent{ +// executeSingleTool runs one tool call. A tool that fails, by any means, +// produces an error result the model can act on. +func (a *agent) executeSingleTool(ctx context.Context, toolMap map[string]AgentTool, execProviderToolMap map[string]ExecutableProviderTool, toolCall ToolCallContent, toolResultCallback func(result ToolResultContent) error) (result ToolResultContent) { + result = ToolResultContent{ ToolCallID: toolCall.ToolCallID, ToolName: toolCall.ToolName, ProviderExecuted: false, } + if toolResultCallback != nil { + defer func() { _ = toolResultCallback(result) }() + } - // Skip invalid tool calls - create error result (not critical) if toolCall.Invalid { - result.Result = ToolResultOutputContentError{ - Error: toolCall.ValidationError, - } - if toolResultCallback != nil { - _ = toolResultCallback(result) - } - return result, false + result.Result = ToolResultOutputContentError{Error: toolCall.ValidationError} + return result } // Find the run function — either from a regular AgentTool or an @@ -805,57 +823,32 @@ func (a *agent) executeSingleTool(ctx context.Context, toolMap map[string]AgentT runTool = ept.Run } if runTool == nil { - result.Result = ToolResultOutputContentError{ - Error: errors.New("tool not found: " + toolCall.ToolName), - } - if toolResultCallback != nil { - _ = toolResultCallback(result) - } - return result, false + result.Result = ToolResultOutputContentError{Error: errors.New("tool not found: " + toolCall.ToolName)} + return result } - // Execute the tool, converting a panic into a failed tool result so a - // single misbehaving tool cannot take down the whole process. The panic - // value and stack are included so they survive in the transcript even - // when stderr is lost. toolResult, err := runToolSafely(ctx, runTool, ToolCall{ ID: toolCall.ToolCallID, Name: toolCall.ToolName, Input: toolCall.Input, }) - if err != nil { - result.Result = ToolResultOutputContentError{ - Error: err, - } - result.ClientMetadata = toolResult.Metadata - result.StopTurn = toolResult.StopTurn - if toolResultCallback != nil { - _ = toolResultCallback(result) - } - return result, true - } - result.ClientMetadata = toolResult.Metadata result.StopTurn = toolResult.StopTurn - if toolResult.IsError { - result.Result = ToolResultOutputContentError{ - Error: errors.New(toolResult.Content), - } - } else if toolResult.Type == "image" || toolResult.Type == "media" { + switch { + case err != nil: + result.Result = ToolResultOutputContentError{Error: err} + case toolResult.IsError: + result.Result = ToolResultOutputContentError{Error: errors.New(toolResult.Content)} + case toolResult.Type == "image", toolResult.Type == "media": result.Result = ToolResultOutputContentMedia{ Data: base64.StdEncoding.EncodeToString(toolResult.Data), MediaType: toolResult.MediaType, Text: toolResult.Content, } - } else { - result.Result = ToolResultOutputContentText{ - Text: toolResult.Content, - } + default: + result.Result = ToolResultOutputContentText{Text: toolResult.Content} } - if toolResultCallback != nil { - _ = toolResultCallback(result) - } - return result, false + return result } // runToolSafely invokes a tool's run function and converts any panic into a @@ -1027,7 +1020,7 @@ func (a *agent) Stream(ctx context.Context, opts AgentStreamCall) (*AgentResult, } // Process the stream - result, err := a.processStepStream(ctx, stream, opts, steps, stepTools, stepExecProviderTools) + result, err := a.processStepStream(ctx, stream, opts, steps, stepTools, stepExecProviderTools, stepInputMessages) if err != nil { return stepExecutionResult{}, err } @@ -1387,7 +1380,7 @@ func WithOnRetry(callback OnRetryCallback) AgentOption { } // processStepStream processes a single step's stream and returns the step result. -func (a *agent) processStepStream(ctx context.Context, stream StreamResponse, opts AgentStreamCall, _ []StepResult, stepTools []AgentTool, execProviderTools []ExecutableProviderTool) (stepExecutionResult, error) { +func (a *agent) processStepStream(ctx context.Context, stream StreamResponse, opts AgentStreamCall, _ []StepResult, stepTools []AgentTool, execProviderTools []ExecutableProviderTool, stepInputMessages []Message) (stepExecutionResult, error) { var stepContent []Content var stepToolCalls []ToolCallContent var stepUsage Usage @@ -1409,6 +1402,16 @@ func (a *agent) processStepStream(ctx context.Context, stream StreamResponse, op parallel bool } var pendingDispatches []toolExecutionRequest + // Raw tool calls as emitted by the provider, before validation/repair, + // each with the position in stepContent at which it was emitted. They + // are processed only after the stream ends and the finish reason is + // known (see below); the position preserves stream order in the + // recorded step content. + type rawToolCall struct { + contentIndex int + toolCall ToolCallContent + } + var rawToolCalls []rawToolCall // Create a map for quick tool lookup toolMap := make(map[string]AgentTool) @@ -1433,7 +1436,7 @@ func (a *agent) processStepStream(ctx context.Context, stream StreamResponse, op switch part.Type { case StreamPartTypeWarnings: - stepWarnings = part.Warnings + stepWarnings = append(stepWarnings, part.Warnings...) if opts.OnWarnings != nil { err := opts.OnWarnings(part.Warnings) if err != nil { @@ -1565,43 +1568,16 @@ func (a *agent) processStepStream(ctx context.Context, stream StreamResponse, op ProviderMetadata: part.ProviderMetadata, } - // Provider-executed tool calls are handled by the provider - // and should not be validated or executed by the agent. - if toolCall.ProviderExecuted { - stepContent = append(stepContent, toolCall) - if opts.OnToolCall != nil { - err := opts.OnToolCall(toolCall) - if err != nil { - return stepExecutionResult{}, err - } - } - delete(activeToolCalls, part.ID) - } else { - // Validate and potentially repair the tool call - validatedToolCall := a.validateAndRepairToolCall(ctx, toolCall, stepTools, execProviderTools, a.settings.systemPrompt, nil, opts.RepairToolCall) - stepToolCalls = append(stepToolCalls, validatedToolCall) - stepContent = append(stepContent, validatedToolCall) - - if opts.OnToolCall != nil { - err := opts.OnToolCall(validatedToolCall) - if err != nil { - return stepExecutionResult{}, err - } - } - - // Determine if tool can run in parallel - isParallel := false - if tool, exists := toolMap[validatedToolCall.ToolName]; exists { - isParallel = tool.Info().Parallel - } - - // Buffer dispatch until stream is fully consumed so that all - // OnToolCall callbacks complete before any tool result is written. - pendingDispatches = append(pendingDispatches, toolExecutionRequest{toolCall: validatedToolCall, parallel: isParallel}) - - // Clean up active tool call - delete(activeToolCalls, part.ID) - } + // Buffer the call. Validation, repair, and the OnToolCall + // callback all wait until the stream has ended and the finish + // reason is known: a provider that emits a ToolCall before a + // length/error finish must not have its possibly-truncated + // arguments repaired (repair may be an extra model call) or + // exposed to consumers — provider-executed calls included; the + // provider may run them, but consumers hear about them only for + // completed turns (CHARM-2020). + rawToolCalls = append(rawToolCalls, rawToolCall{contentIndex: len(stepContent), toolCall: toolCall}) + delete(activeToolCalls, part.ID) case StreamPartTypeToolResult: // Provider-executed tool results (e.g. web search) @@ -1655,68 +1631,118 @@ func (a *agent) processStepStream(ctx context.Context, stream StreamResponse, op } } - // All tool calls are now collected. Create the execution channel sized to - // avoid blocking during dispatch, start the coordinator, then flush the batch. - toolChan := make(chan toolExecutionRequest, len(pendingDispatches)) - var toolExecutionWg sync.WaitGroup - var toolStateMu sync.Mutex - toolResults := make([]ToolResultContent, 0, len(pendingDispatches)) - var toolExecutionErr error - - // Semaphores for controlling parallelism. - parallelSem := make(chan struct{}, 5) - var sequentialMu sync.Mutex - - // Single coordinator goroutine that dispatches tools. - toolExecutionWg.Go(func() { - for req := range toolChan { - if req.parallel { - parallelSem <- struct{}{} - toolExecutionWg.Go(func() { - defer func() { <-parallelSem }() - result, isCriticalError := a.executeSingleTool(ctx, toolMap, execProviderToolMap, req.toolCall, opts.OnToolResult) - toolStateMu.Lock() - toolResults = append(toolResults, result) - if isCriticalError && toolExecutionErr == nil { - if errorResult, ok := result.Result.(ToolResultOutputContentError); ok && errorResult.Error != nil { - toolExecutionErr = errorResult.Error - } - } - toolStateMu.Unlock() - }) - } else { - sequentialMu.Lock() - result, isCriticalError := a.executeSingleTool(ctx, toolMap, execProviderToolMap, req.toolCall, opts.OnToolResult) - toolStateMu.Lock() - toolResults = append(toolResults, result) - if isCriticalError && toolExecutionErr == nil { - if errorResult, ok := result.Result.(ToolResultOutputContentError); ok && errorResult.Error != nil { - toolExecutionErr = errorResult.Error - } + // Process buffered tool calls now that the finish reason is known. + // Abnormal finishes — length, content filter, provider error, unknown — + // can accompany arguments that were cut short: record the raw call in + // the step content, but never repair (repair may be an extra model + // call), never fire OnToolCall, and never dispatch (CHARM-2020). + abnormalFinish := stepFinishReason == FinishReasonLength || + stepFinishReason == FinishReasonError || + stepFinishReason == FinishReasonContentFilter || + stepFinishReason == FinishReasonUnknown + // Splice each processed call into the position it was emitted at in the + // stream, so step content preserves the provider's ordering. Calls + // emitted back-to-back share the same recorded index; track insertions + // so they land in emission order, not reversed. + insertContent := func(at int, c Content) { + stepContent = append(stepContent, nil) + copy(stepContent[at+1:], stepContent[at:]) + stepContent[at] = c + } + inserted := 0 + for _, raw := range rawToolCalls { + at := raw.contentIndex + inserted + toolCall := raw.toolCall + if abnormalFinish { + stepToolCalls = append(stepToolCalls, toolCall) + insertContent(at, toolCall) + inserted++ + continue + } + // Provider-executed tool calls (e.g. web search) were already run by + // the provider: record them and notify, but never validate, repair, + // or dispatch them. + if toolCall.ProviderExecuted { + insertContent(at, toolCall) + inserted++ + if opts.OnToolCall != nil { + err := opts.OnToolCall(toolCall) + if err != nil { + return stepExecutionResult{}, err } - toolStateMu.Unlock() - sequentialMu.Unlock() } + continue + } + // Validate and potentially repair the tool call + validatedToolCall := a.validateAndRepairToolCall(ctx, toolCall, stepTools, execProviderTools, a.settings.systemPrompt, stepInputMessages, opts.RepairToolCall) + stepToolCalls = append(stepToolCalls, validatedToolCall) + insertContent(at, validatedToolCall) + inserted++ + + if opts.OnToolCall != nil { + err := opts.OnToolCall(validatedToolCall) + if err != nil { + return stepExecutionResult{}, err + } + } + + // Determine if tool can run in parallel + isParallel := false + if tool, exists := toolMap[validatedToolCall.ToolName]; exists { + isParallel = tool.Info().Parallel } - }) - // Dispatch all buffered tool calls now that every OnToolCall callback has - // been called, then close and wait. - for _, req := range pendingDispatches { - toolChan <- req + // Buffer dispatch until stream is fully consumed so that all + // OnToolCall callbacks complete before any tool result is written. + pendingDispatches = append(pendingDispatches, toolExecutionRequest{toolCall: validatedToolCall, parallel: isParallel}) } - // Close the tool execution channel and wait for all executions to complete. - close(toolChan) - toolExecutionWg.Wait() + // Run the buffered calls now that every OnToolCall callback has fired. + // Dispatch only on an explicit tool-calls turn: any other finish reason + // can accompany arguments that were cut short, and running those is how + // truncated input reaches tools (CHARM-2020). + toolResults := make([]ToolResultContent, 0, len(pendingDispatches)) + if stepFinishReason == FinishReasonToolCalls { + var wg sync.WaitGroup + var mu sync.Mutex + parallelSem := make(chan struct{}, 5) + run := func(req toolExecutionRequest) { + result := a.executeSingleTool(ctx, toolMap, execProviderToolMap, req.toolCall, opts.OnToolResult) + mu.Lock() + toolResults = append(toolResults, result) + mu.Unlock() + } + for _, req := range pendingDispatches { + if !req.parallel { + run(req) + continue + } + parallelSem <- struct{}{} + wg.Go(func() { + defer func() { <-parallelSem }() + run(req) + }) + } + wg.Wait() + } - // Check for tool execution errors - if toolExecutionErr != nil { - return stepExecutionResult{}, toolExecutionErr + // Stop a canceled run rather than looping back to the model. + if err := ctx.Err(); err != nil { + return stepExecutionResult{}, err } - // Add tool results to content if any + // Add tool results to content in the order the model called the tools, + // not the order they completed in (F6, CHARM-2020). Results carry their + // call id; providers that pair results with calls positionally, and any + // consumer diffing step content, depend on call order. if len(toolResults) > 0 { + positionByCallID := make(map[string]int, len(pendingDispatches)) + for i, req := range pendingDispatches { + positionByCallID[req.toolCall.ToolCallID] = i + } + slices.SortStableFunc(toolResults, func(a, b ToolResultContent) int { + return cmp.Compare(positionByCallID[a.ToolCallID], positionByCallID[b.ToolCallID]) + }) for _, result := range toolResults { stepContent = append(stepContent, result) } diff --git a/agent_stream_test.go b/agent_stream_test.go index 13e0ac3d2..95227af3e 100644 --- a/agent_stream_test.go +++ b/agent_stream_test.go @@ -3,10 +3,14 @@ package fantasy import ( "context" "encoding/json" + "errors" "fmt" + "net" "strings" "sync" + "sync/atomic" "testing" + "time" "github.com/stretchr/testify/require" ) @@ -761,3 +765,427 @@ func TestStreamingAgent_StopTurn(t *testing.T) { require.Len(t, responseResults, 1) require.True(t, responseResults[0].StopTurn) } + +func TestStreamingAgent_SkipsToolDispatchWhenTruncated(t *testing.T) { + t.Parallel() + + var toolExecuted bool + mockModel := &mockLanguageModel{ + streamFunc: func(ctx context.Context, call Call) (StreamResponse, error) { + return func(yield func(StreamPart) bool) { + // Emit a tool call with truncated arguments + if !yield(StreamPart{Type: StreamPartTypeToolInputStart, ID: "call-trunc", ToolCallName: "echo"}) { + return + } + if !yield(StreamPart{Type: StreamPartTypeToolInputDelta, ID: "call-trunc", Delta: `{"message":"tr`}) { + return + } + if !yield(StreamPart{Type: StreamPartTypeToolInputEnd, ID: "call-trunc"}) { + return + } + if !yield(StreamPart{ + Type: StreamPartTypeToolCall, + ID: "call-trunc", + ToolCallName: "echo", + ToolCallInput: `{"message":"tr`, + }) { + return + } + // Finish with length instead of tool_calls + yield(StreamPart{ + Type: StreamPartTypeFinish, + Usage: Usage{InputTokens: 10, OutputTokens: 20, TotalTokens: 30}, + FinishReason: FinishReasonLength, + }) + }, nil + }, + } + + echoTool := &trackingEchoTool{onExecute: func() { toolExecuted = true }} + agent := NewAgent( + mockModel, + WithTools(echoTool), + ) + + result, err := agent.Stream(context.Background(), AgentStreamCall{ + Prompt: "test", + }) + require.NoError(t, err) + require.Equal(t, FinishReasonLength, result.Response.FinishReason) + require.False(t, toolExecuted, "tool must not be dispatched when finish_reason is length") + require.Len(t, result.Steps, 1) +} + +// trackingEchoTool wraps EchoTool to track whether Run was called. +type trackingEchoTool struct { + onExecute func() + providerOptions ProviderOptions +} + +func (t *trackingEchoTool) Info() ToolInfo { + return (&EchoTool{}).Info() +} + +func (t *trackingEchoTool) Run(ctx context.Context, params ToolCall) (ToolResponse, error) { + if t.onExecute != nil { + t.onExecute() + } + return (&EchoTool{}).Run(ctx, params) +} + +func (t *trackingEchoTool) ProviderOptions() ProviderOptions { + return t.providerOptions +} + +func (t *trackingEchoTool) SetProviderOptions(opts ProviderOptions) { + t.providerOptions = opts +} + +// TestStreamingAgent_SkipsToolDispatchOnAbnormalFinish generalizes the +// truncation guard: any finish reason that is not an explicit tool-calls +// turn must not dispatch, even if ToolCall parts were emitted (a buggy or +// unusual provider path, or a finish reason the provider layer maps from +// an upstream failure such as content_filter or +// insufficient_system_resource). See CHARM-2020. +func TestStreamingAgent_SkipsToolDispatchOnAbnormalFinish(t *testing.T) { + t.Parallel() + + for _, reason := range []FinishReason{FinishReasonError, FinishReasonContentFilter, FinishReasonUnknown} { + t.Run(string(reason), func(t *testing.T) { + t.Parallel() + + var toolExecuted bool + mockModel := &mockLanguageModel{ + streamFunc: func(ctx context.Context, call Call) (StreamResponse, error) { + return func(yield func(StreamPart) bool) { + if !yield(StreamPart{Type: StreamPartTypeToolInputStart, ID: "call-x", ToolCallName: "echo"}) { + return + } + if !yield(StreamPart{Type: StreamPartTypeToolInputDelta, ID: "call-x", Delta: `{"message":"hi"}`}) { + return + } + if !yield(StreamPart{Type: StreamPartTypeToolInputEnd, ID: "call-x"}) { + return + } + if !yield(StreamPart{Type: StreamPartTypeToolCall, ID: "call-x", ToolCallName: "echo", ToolCallInput: `{"message":"hi"}`}) { + return + } + yield(StreamPart{ + Type: StreamPartTypeFinish, + Usage: Usage{InputTokens: 10, OutputTokens: 20, TotalTokens: 30}, + FinishReason: reason, + }) + }, nil + }, + } + + echoTool := &trackingEchoTool{onExecute: func() { toolExecuted = true }} + agent := NewAgent(mockModel, WithTools(echoTool)) + + result, err := agent.Stream(context.Background(), AgentStreamCall{Prompt: "test"}) + require.NoError(t, err) + require.False(t, toolExecuted, "tool must not be dispatched when finish_reason is %s", reason) + require.Equal(t, reason, result.Response.FinishReason) + require.Len(t, result.Steps, 1) + }) + } +} + +// TestStreamingAgent_ParallelToolResultsInCallOrder guards CHARM-2020 F6: +// two parallel tools where the second finishes first must still append +// results in the order the model called them. Providers that pair results +// with calls positionally (and any diff of step content) depend on it. +func TestStreamingAgent_ParallelToolResultsInCallOrder(t *testing.T) { + t.Parallel() + + type input struct { + N int `json:"n"` + } + slow := NewParallelAgentTool("slow", "finishes last", + func(ctx context.Context, in input, call ToolCall) (ToolResponse, error) { + time.Sleep(50 * time.Millisecond) + return NewTextResponse("slow-result"), nil + }) + fast := NewParallelAgentTool("fast", "finishes first", + func(ctx context.Context, in input, call ToolCall) (ToolResponse, error) { + return NewTextResponse("fast-result"), nil + }) + + mockModel := &mockLanguageModel{ + streamFunc: func(ctx context.Context, call Call) (StreamResponse, error) { + for _, msg := range call.Prompt { + if msg.Role == MessageRoleTool { + return func(yield func(StreamPart) bool) { + yield(StreamPart{Type: StreamPartTypeTextStart, ID: "t"}) + yield(StreamPart{Type: StreamPartTypeTextDelta, ID: "t", Delta: "done"}) + yield(StreamPart{Type: StreamPartTypeTextEnd, ID: "t"}) + yield(StreamPart{Type: StreamPartTypeFinish, FinishReason: FinishReasonStop, Usage: Usage{TotalTokens: 5}}) + }, nil + } + } + return func(yield func(StreamPart) bool) { + if !yield(StreamPart{Type: StreamPartTypeToolCall, ID: "call-slow", ToolCallName: "slow", ToolCallInput: `{"n":1}`}) { + return + } + if !yield(StreamPart{Type: StreamPartTypeToolCall, ID: "call-fast", ToolCallName: "fast", ToolCallInput: `{"n":2}`}) { + return + } + yield(StreamPart{Type: StreamPartTypeFinish, FinishReason: FinishReasonToolCalls, Usage: Usage{TotalTokens: 10}}) + }, nil + }, + } + + agent := NewAgent(mockModel, WithTools(slow, fast)) + result, err := agent.Stream(context.Background(), AgentStreamCall{Prompt: "run both"}) + require.NoError(t, err) + require.Len(t, result.Steps, 2) + + var order []string + for _, c := range result.Steps[0].Content { + if tr, ok := AsContentType[ToolResultContent](c); ok { + order = append(order, tr.ToolCallID) + } + } + require.Equal(t, []string{"call-slow", "call-fast"}, order, + "results must follow call order, not completion order") +} + +// TestStreamingAgent_NoRepairOrOnToolCallBeforeAbnormalFinish pins the +// CHARM-2020 "never repair" requirement for the stream path: a provider +// that emits a ToolCall part and only then finishes with length/error must +// not have the call repaired (an extra model call) or exposed to +// OnToolCall consumers. +func TestStreamingAgent_NoRepairOrOnToolCallBeforeAbnormalFinish(t *testing.T) { + t.Parallel() + + for _, reason := range []FinishReason{FinishReasonLength, FinishReasonError, FinishReasonContentFilter, FinishReasonUnknown} { + t.Run(string(reason), func(t *testing.T) { + t.Parallel() + + var repaired, onToolCallFired, executed bool + model := &mockLanguageModel{ + streamFunc: func(ctx context.Context, call Call) (StreamResponse, error) { + return func(yield func(StreamPart) bool) { + // ToolCall emitted BEFORE the finish part. + if !yield(StreamPart{Type: StreamPartTypeToolCall, ID: "call-x", ToolCallName: "echo", ToolCallInput: `{"message":"tr`}) { + return + } + yield(StreamPart{Type: StreamPartTypeFinish, FinishReason: reason, Usage: Usage{TotalTokens: 10}}) + }, nil + }, + } + + agent := NewAgent( + model, + WithTools(&trackingEchoTool{onExecute: func() { executed = true }}), + WithRepairToolCall(func(ctx context.Context, options ToolCallRepairOptions) (*ToolCallContent, error) { + repaired = true + c := options.OriginalToolCall + c.Input = `{"message":"fixed"}` + return &c, nil + }), + ) + + result, err := agent.Stream(context.Background(), AgentStreamCall{ + Prompt: "test", + OnToolCall: func(ToolCallContent) error { + onToolCallFired = true + return nil + }, + }) + require.NoError(t, err) + require.False(t, repaired, "repair must not run when finish is %s", reason) + require.False(t, onToolCallFired, "OnToolCall must not fire when finish is %s", reason) + require.False(t, executed, "tool must not execute when finish is %s", reason) + require.Len(t, result.Steps, 1) + // The raw call is still recorded for the step. + var calls []ToolCallContent + for _, c := range result.Steps[0].Content { + if tc, ok := AsContentType[ToolCallContent](c); ok { + calls = append(calls, tc) + } + } + require.Len(t, calls, 1) + require.Equal(t, `{"message":"tr`, calls[0].Input, "raw (unrepaired) input must be recorded") + }) + } +} + +// TestStreamingAgent_ProviderExecutedToolCallGatedOnFinish extends the +// late-binding guarantee to provider-executed calls: OnToolCall must not +// fire for them before the finish reason is known either. +func TestStreamingAgent_ProviderExecutedToolCallGatedOnFinish(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + reason FinishReason + wantNotified bool + }{ + {FinishReasonToolCalls, true}, + {FinishReasonLength, false}, + {FinishReasonError, false}, + } { + t.Run(string(tc.reason), func(t *testing.T) { + t.Parallel() + + var onToolCallFired bool + model := &mockLanguageModel{ + streamFunc: func(ctx context.Context, call Call) (StreamResponse, error) { + return func(yield func(StreamPart) bool) { + // Provider-executed tool call (e.g. web search) + if !yield(StreamPart{Type: StreamPartTypeToolCall, ID: "ws-1", ToolCallName: "web_search", ToolCallInput: `{"q":"x"}`, ProviderExecuted: true}) { + return + } + if !yield(StreamPart{Type: StreamPartTypeToolResult, ID: "ws-1", ToolCallName: "web_search", ProviderExecuted: true}) { + return + } + yield(StreamPart{Type: StreamPartTypeFinish, FinishReason: tc.reason, Usage: Usage{TotalTokens: 10}}) + }, nil + }, + } + + agent := NewAgent(model) + result, err := agent.Stream(context.Background(), AgentStreamCall{ + Prompt: "test", + OnToolCall: func(ToolCallContent) error { + onToolCallFired = true + return nil + }, + }) + require.NoError(t, err) + require.Equal(t, tc.wantNotified, onToolCallFired, + "OnToolCall fired=%v for provider-executed call with finish %s", onToolCallFired, tc.reason) + require.Len(t, result.Steps, 1) + }) + } +} + +// TestStreamingAgent_ToolErrorReachesTheModel pins that a Go error returned +// from a tool's Run function becomes an error result the model can act on: +// the run continues, the tool runs once, and OnRetry never fires. The tool +// error here satisfies net.Error, which the retry loop treats as a transient +// network failure when it comes from the provider request. +func TestStreamingAgent_ToolErrorReachesTheModel(t *testing.T) { + t.Parallel() + + type input struct{} + toolErr := &net.OpError{Op: "dial", Net: "tcp", Err: errors.New("connection refused")} + + for _, tc := range []struct { + name string + tool func(runs *atomic.Int32) AgentTool + }{ + { + name: "sequential tool", + tool: func(runs *atomic.Int32) AgentTool { + return NewAgentTool("boom", "always fails", + func(ctx context.Context, in input, call ToolCall) (ToolResponse, error) { + runs.Add(1) + return ToolResponse{}, toolErr + }) + }, + }, + { + name: "parallel tool", + tool: func(runs *atomic.Int32) AgentTool { + return NewParallelAgentTool("boom", "always fails", + func(ctx context.Context, in input, call ToolCall) (ToolResponse, error) { + runs.Add(1) + return ToolResponse{}, toolErr + }) + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var toolRuns, streamCalls atomic.Int32 + mockModel := &mockLanguageModel{ + streamFunc: func(ctx context.Context, call Call) (StreamResponse, error) { + n := streamCalls.Add(1) + return func(yield func(StreamPart) bool) { + if n == 1 { + if !yield(StreamPart{Type: StreamPartTypeToolCall, ID: "call-1", ToolCallName: "boom", ToolCallInput: `{}`}) { + return + } + yield(StreamPart{Type: StreamPartTypeFinish, FinishReason: FinishReasonToolCalls, Usage: Usage{TotalTokens: 10}}) + return + } + if !yield(StreamPart{Type: StreamPartTypeTextStart, ID: "t1"}) { + return + } + if !yield(StreamPart{Type: StreamPartTypeTextDelta, ID: "t1", Delta: "could not reach it"}) { + return + } + if !yield(StreamPart{Type: StreamPartTypeTextEnd, ID: "t1"}) { + return + } + yield(StreamPart{Type: StreamPartTypeFinish, FinishReason: FinishReasonStop, Usage: Usage{TotalTokens: 10}}) + }, nil + }, + } + + agent := NewAgent(mockModel, WithTools(tc.tool(&toolRuns))) + retries := 0 + res, err := agent.Stream(context.Background(), AgentStreamCall{ + Prompt: "run boom", + OnRetry: func(err *ProviderError, delay time.Duration) { + retries++ + }, + }) + require.NoError(t, err, "a tool failure must not end the run") + require.Equal(t, FinishReasonStop, res.Response.FinishReason) + + var results []ToolResultContent + for _, step := range res.Steps { + results = append(results, step.Content.ToolResults()...) + } + require.Len(t, results, 1) + errResult, ok := results[0].Result.(ToolResultOutputContentError) + require.True(t, ok, "the tool failure must be recorded as an error result") + require.ErrorIs(t, errResult.Error, toolErr, "the tool's own error must stay reachable") + + require.Equal(t, int32(2), streamCalls.Load(), "the model must see the error and get to respond") + require.Equal(t, int32(1), toolRuns.Load(), "the tool must not be re-executed") + require.Equal(t, 0, retries, "OnRetry must not fire for a tool error") + }) + } +} + +// TestStreamingAgent_CancelDuringToolEndsRun pins that cancellation still +// ends the run now that a tool's error no longer does. The tool reports a +// plain failure rather than the context error, so the only thing that can +// stop the loop is the loop's own view of the context. +func TestStreamingAgent_CancelDuringToolEndsRun(t *testing.T) { + t.Parallel() + + type input struct{} + ctx, cancel := context.WithCancel(context.Background()) + + var toolRuns, streamCalls atomic.Int32 + tool := NewAgentTool("boom", "cancels the run", + func(ctx context.Context, in input, call ToolCall) (ToolResponse, error) { + toolRuns.Add(1) + cancel() + return ToolResponse{}, errors.New("could not finish") + }) + + mockModel := &mockLanguageModel{ + streamFunc: func(ctx context.Context, call Call) (StreamResponse, error) { + streamCalls.Add(1) + return func(yield func(StreamPart) bool) { + if !yield(StreamPart{Type: StreamPartTypeToolCall, ID: "call-1", ToolCallName: "boom", ToolCallInput: `{}`}) { + return + } + yield(StreamPart{Type: StreamPartTypeFinish, FinishReason: FinishReasonToolCalls, Usage: Usage{TotalTokens: 10}}) + }, nil + }, + } + + agent := NewAgent(mockModel, WithTools(tool)) + _, err := agent.Stream(ctx, AgentStreamCall{Prompt: "run boom"}) + + require.ErrorIs(t, err, context.Canceled) + require.Equal(t, int32(1), streamCalls.Load(), "the model must not be asked again after a cancel") + require.Equal(t, int32(1), toolRuns.Load()) +} diff --git a/agent_test.go b/agent_test.go index bcc7e394f..2a1c4a107 100644 --- a/agent_test.go +++ b/agent_test.go @@ -2326,6 +2326,22 @@ func TestAgent_Stream_ExecutableProviderTool(t *testing.T) { model := &mockLanguageModel{ streamFunc: func(ctx context.Context, call Call) (StreamResponse, error) { + // First call issues the tool call; after the tool result comes + // back the model stops, so the agent loop terminates. + for _, msg := range call.Prompt { + if msg.Role == MessageRoleTool { + return func(yield func(StreamPart) bool) { + yield(StreamPart{Type: StreamPartTypeTextStart, ID: "t"}) + yield(StreamPart{Type: StreamPartTypeTextDelta, ID: "t", Delta: "done"}) + yield(StreamPart{Type: StreamPartTypeTextEnd, ID: "t"}) + yield(StreamPart{ + Type: StreamPartTypeFinish, + FinishReason: FinishReasonStop, + Usage: Usage{TotalTokens: 5}, + }) + }, nil + } + } return func(yield func(StreamPart) bool) { if !yield(StreamPart{ Type: StreamPartTypeToolCall, @@ -2337,7 +2353,7 @@ func TestAgent_Stream_ExecutableProviderTool(t *testing.T) { } yield(StreamPart{ Type: StreamPartTypeFinish, - FinishReason: FinishReasonStop, + FinishReason: FinishReasonToolCalls, Usage: Usage{TotalTokens: 10}, }) }, nil @@ -2352,7 +2368,7 @@ func TestAgent_Stream_ExecutableProviderTool(t *testing.T) { require.NoError(t, err) require.NotNil(t, result) require.True(t, runCalled, "expected Run func to be called") - require.Len(t, result.Steps, 1) + require.Len(t, result.Steps, 2) // Verify tool result is in the step content. var toolResults []ToolResultContent @@ -2545,10 +2561,10 @@ func TestAgent_Generate_ExecutableProviderTool_ImageBase64(t *testing.T) { require.Equal(t, "image/png", mediaResult.MediaType) } -// TestAgent_Generate_ExecutableProviderTool_CriticalError verifies -// that a Go error returned from an ExecutableProviderTool's run -// function is treated as a critical error, stopping the agent loop. -func TestAgent_Generate_ExecutableProviderTool_CriticalError(t *testing.T) { +// TestAgent_Generate_ExecutableProviderTool_Error verifies that a Go error +// returned from an ExecutableProviderTool's run function reaches the model +// as an error result, like any other tool failure. +func TestAgent_Generate_ExecutableProviderTool_Error(t *testing.T) { t.Parallel() execTool := NewExecutableProviderTool( @@ -2566,6 +2582,13 @@ func TestAgent_Generate_ExecutableProviderTool_CriticalError(t *testing.T) { model := &mockLanguageModel{ generateFunc: func(ctx context.Context, call Call) (*Response, error) { callCount++ + if callCount > 1 { + return &Response{ + Content: []Content{TextContent{Text: "the screen is unavailable"}}, + Usage: Usage{TotalTokens: 10}, + FinishReason: FinishReasonStop, + }, nil + } return &Response{ Content: []Content{ ToolCallContent{ @@ -2587,10 +2610,53 @@ func TestAgent_Generate_ExecutableProviderTool_CriticalError(t *testing.T) { require.NoError(t, err) require.NotNil(t, result) - // The model should only be called once — the critical error stops - // the loop before a second model call. - require.Equal(t, 1, callCount) - require.Len(t, result.Steps, 1) + // The tool failure is reported to the model, which gets to answer. + require.Equal(t, 2, callCount) + require.Len(t, result.Steps, 2) + + results := result.Steps[0].Content.ToolResults() + require.Len(t, results, 1) + errResult, ok := results[0].Result.(ToolResultOutputContentError) + require.True(t, ok, "the tool failure must be recorded as an error result") + require.EqualError(t, errResult.Error, "vnc connection lost") +} + +// TestAgent_Generate_CancelDuringToolEndsRun pins that a cancel still ends a +// Generate run now that a tool's error no longer does. +func TestAgent_Generate_CancelDuringToolEndsRun(t *testing.T) { + t.Parallel() + + type input struct{} + ctx, cancel := context.WithCancel(context.Background()) + + toolRuns := 0 + tool := NewAgentTool("boom", "cancels the run", + func(ctx context.Context, in input, call ToolCall) (ToolResponse, error) { + toolRuns++ + cancel() + return ToolResponse{}, errors.New("could not finish") + }) + + callCount := 0 + model := &mockLanguageModel{ + generateFunc: func(ctx context.Context, call Call) (*Response, error) { + callCount++ + return &Response{ + Content: []Content{ + ToolCallContent{ToolCallID: "call-1", ToolName: "boom", Input: `{}`}, + }, + Usage: Usage{TotalTokens: 10}, + FinishReason: FinishReasonToolCalls, + }, nil + }, + } + + agent := NewAgent(model, WithTools(tool)) + _, err := agent.Generate(ctx, AgentCall{Prompt: "run boom"}) + + require.ErrorIs(t, err, context.Canceled) + require.Equal(t, 1, callCount, "the model must not be asked again after a cancel") + require.Equal(t, 1, toolRuns) } func TestAgent_Generate_StopTurn(t *testing.T) { @@ -2831,3 +2897,129 @@ func TestAgent_Generate_ParallelToolPanicBecomesFailedResult(t *testing.T) { require.ErrorContains(t, errResult.Error, "panicking_parallel_tool") require.ErrorContains(t, errResult.Error, "parallel boom") } + +// TestAgent_SkipsToolDispatchOnAbnormalFinish_NonStreaming mirrors the +// stream-path guard on Generate: a response carrying tool calls but ending +// length, content-filter, or error must not execute them — the arguments +// may have been cut short (CHARM-2020). +func TestAgent_SkipsToolDispatchOnAbnormalFinish_NonStreaming(t *testing.T) { + t.Parallel() + + for _, reason := range []FinishReason{FinishReasonLength, FinishReasonError, FinishReasonContentFilter, FinishReasonUnknown} { + t.Run(string(reason), func(t *testing.T) { + t.Parallel() + + var toolExecuted bool + model := &mockLanguageModel{ + generateFunc: func(ctx context.Context, call Call) (*Response, error) { + return &Response{ + Content: []Content{ + ToolCallContent{ToolCallID: "call-x", ToolName: "echo", Input: `{"message":"hi"}`}, + }, + FinishReason: reason, + Usage: Usage{TotalTokens: 10}, + }, nil + }, + } + + echoTool := &trackingEchoTool{onExecute: func() { toolExecuted = true }} + agent := NewAgent(model, WithTools(echoTool)) + + result, err := agent.Generate(context.Background(), AgentCall{Prompt: "test"}) + require.NoError(t, err) + require.False(t, toolExecuted, "tool must not be dispatched when finish_reason is %s", reason) + require.Equal(t, reason, result.Response.FinishReason) + require.Len(t, result.Steps, 1) + }) + } +} + +// TestAgent_StopWithToolCallsStillDispatches documents the tolerated shape: +// some providers report stop on a tool turn. Unlike abnormal finishes +// (length/error/content-filter/unknown), stop carries no truncation risk, +// so dispatch proceeds. +func TestAgent_StopWithToolCallsStillDispatches(t *testing.T) { + t.Parallel() + + var toolExecuted bool + model := &mockLanguageModel{ + generateFunc: func(ctx context.Context, call Call) (*Response, error) { + for _, m := range call.Prompt { + if m.Role == MessageRoleTool { + return &Response{ + Content: []Content{TextContent{Text: "done"}}, + FinishReason: FinishReasonStop, + Usage: Usage{TotalTokens: 5}, + }, nil + } + } + return &Response{ + Content: []Content{ + ToolCallContent{ToolCallID: "call-x", ToolName: "echo", Input: `{"message":"hi"}`}, + }, + FinishReason: FinishReasonStop, + Usage: Usage{TotalTokens: 10}, + }, nil + }, + } + + echoTool := &trackingEchoTool{onExecute: func() { toolExecuted = true }} + agent := NewAgent(model, WithTools(echoTool)) + + result, err := agent.Generate(context.Background(), AgentCall{Prompt: "test"}) + require.NoError(t, err) + require.True(t, toolExecuted, "stop with tool calls must still dispatch") + // The loop stops after one step because the finish reason is stop, not + // tool_calls — but the tool result is recorded in the step content. + require.Len(t, result.Steps, 1) + var results []ToolResultContent + for _, c := range result.Steps[0].Content { + if tr, ok := AsContentType[ToolResultContent](c); ok { + results = append(results, tr) + } + } + require.Len(t, results, 1) + require.Equal(t, "call-x", results[0].ToolCallID) +} + +// TestAgent_NoRepairOnAbnormalFinish_NonStreaming guards CHARM-2020: when a +// response ends abnormally, the agent must not invoke the repair callback — +// which can be an extra model call — on arguments that may be truncated. +func TestAgent_NoRepairOnAbnormalFinish_NonStreaming(t *testing.T) { + t.Parallel() + + for _, reason := range []FinishReason{FinishReasonLength, FinishReasonError, FinishReasonContentFilter, FinishReasonUnknown} { + t.Run(string(reason), func(t *testing.T) { + t.Parallel() + + var repaired bool + model := &mockLanguageModel{ + generateFunc: func(ctx context.Context, call Call) (*Response, error) { + return &Response{ + Content: []Content{ + ToolCallContent{ToolCallID: "call-x", ToolName: "echo", Input: `{"message":"tr`}, + }, + FinishReason: reason, + Usage: Usage{TotalTokens: 10}, + }, nil + }, + } + + agent := NewAgent( + model, + WithTools(&trackingEchoTool{}), + WithRepairToolCall(func(ctx context.Context, options ToolCallRepairOptions) (*ToolCallContent, error) { + repaired = true + repairedCall := options.OriginalToolCall + repairedCall.Input = `{"message":"fixed"}` + return &repairedCall, nil + }), + ) + + result, err := agent.Generate(context.Background(), AgentCall{Prompt: "test"}) + require.NoError(t, err) + require.False(t, repaired, "repair must not run when finish_reason is %s", reason) + require.Len(t, result.Steps, 1) + }) + } +} diff --git a/errors.go b/errors.go index d7861fc13..87fbf9fdd 100644 --- a/errors.go +++ b/errors.go @@ -57,6 +57,18 @@ type ProviderError struct { // events ride inside an already-successful 200 response, so the status // code alone cannot signal that a retry may succeed. TransientError bool + + // ErrorType is the provider's own name for the failure, e.g. + // "overloaded_error", "rate_limit_exceeded", "RESOURCE_EXHAUSTED". + // Providers already parse it to classify transient failures, so carrying + // it spares callers from reading it back out of Message, which is + // formatted for people rather than for parsing. + // + // Empty when the response named no type, and always empty for a failure + // a provider recognises by message alone rather than from a classified + // payload. Treat it as a hint worth acting on when present rather than a + // field every error carries. + ErrorType string } func (m *ProviderError) Error() string { @@ -131,37 +143,63 @@ func NewIncompleteStreamError() *ProviderError { } // http2TransportErrorFragments are message fragments that identify a -// transient HTTP/2 transport failure. Go's standard library bundles its -// own copy of the http2 package whose error types are unexported, so they -// cannot be matched with errors.As. We fall back to matching these stable -// fragments, which both the stdlib and x/net/http2 use. The list is kept -// tight to avoid misclassifying application-level errors as transport -// failures. +// transient HTTP/2 transport failure. Go's standard library carries its own +// copy of the http2 package, since Go 1.27 at net/http/internal/http2, whose +// types an importer cannot name, so errors.As against x/net/http2 never +// matches one and only the message is left to go on. The list is kept tight +// to avoid misclassifying application-level errors as transport failures. +// +// These name protocol errors shaped "...: stream ID N; CODE", which is what +// cleanHTTP2ErrorMessage trims. A dead connection goes in +// http2ConnectionLostMessages instead. var http2TransportErrorFragments = []string{ "stream error:", // RST_STREAM: INTERNAL_ERROR, REFUSED_STREAM, CANCEL, etc. "connection error:", // connection-level protocol error } +// http2ConnectionLostMessages report an HTTP/2 connection that died under a +// request, rather than a protocol error carried over a live one. Retrying is +// the remedy, since a retry dials afresh, but they arrive as plain errors +// that are not net.Error and carry no type to match, so without this they +// read as permanent. +// +// Matched in full: they share no framing to parse, and the trailing frame +// detail on GOAWAY would misread as the "; CODE" of a stream error. +var http2ConnectionLostMessages = []string{ + "http2: client connection lost", // health-check ping went unanswered + "http2: server sent GOAWAY and closed the connection", // GoAwayError, from either http2 copy +} + +// matchConnectionLost returns the entry of http2ConnectionLostMessages that +// msg contains, so a caller can both detect one and name it. +func matchConnectionLost(msg string) (string, bool) { + for _, lost := range http2ConnectionLostMessages { + if strings.Contains(msg, lost) { + return lost, true + } + } + return "", false +} + // IsTransportError reports whether err or any error in its chain is a // transient transport-level failure that is safe to retry on a fresh // connection. In practice these are HTTP/2 stream resets, connection // errors, and GOAWAY frames, which originate from the transport rather // than the application. // -// x/net/http2 error types are matched by type; Go's stdlib-bundled http2 -// uses unexported types, so those are matched by their message fragments. +// Stream and connection errors are matched by type against x/net/http2. +// GOAWAY is matched by message instead: x/net deprecated GoAwayError +// without offering a replacement, and the standard library's copy of the +// package is internal, so neither one's type can be named here for long. +// Both spell the failure the same way, so one fragment covers them. func IsTransportError(err error) bool { if err == nil { return false } - var ( - streamErr http2.StreamError - connErr http2.ConnectionError - goAwayErr http2.GoAwayError - ) - if errors.As(err, &streamErr) || - errors.As(err, &connErr) || - errors.As(err, &goAwayErr) { + if _, ok := errors.AsType[http2.StreamError](err); ok { + return true + } + if _, ok := errors.AsType[http2.ConnectionError](err); ok { return true } // Wrapped errors embed the inner message, so scanning the top-level @@ -172,7 +210,8 @@ func IsTransportError(err error) bool { return true } } - return false + _, lost := matchConnectionLost(msg) + return lost } // NewTransportError wraps a transient transport error into a retryable @@ -231,8 +270,15 @@ func WrapTransportError(err error) error { // "stream error: stream ID 27; INTERNAL_ERROR; received from peer" → "INTERNAL_ERROR (received from peer)" // "stream error: stream ID 5; REFUSED_STREAM" → "REFUSED_STREAM" // "http2: connection error: INTERNAL_ERROR" → "INTERNAL_ERROR" +// "http2: client connection lost" → "client connection lost" +// "http2: server sent GOAWAY ...; LastStreamID=5, ErrCode=NO_ERROR" → "server sent GOAWAY and closed the connection" func extractHTTP2ErrorMessage(err error) string { msg := err.Error() + // Returned whole: these are already a sentence, and cleaning GOAWAY + // would strip it down to its trailing frame detail. + if lost, ok := matchConnectionLost(msg); ok { + return strings.TrimPrefix(lost, "http2: ") + } for _, fragment := range http2TransportErrorFragments { if i := strings.Index(msg, fragment); i != -1 { return cleanHTTP2ErrorMessage(msg[i:]) @@ -309,6 +355,6 @@ func (e *NoObjectGeneratedError) Error() string { // IsNoObjectGeneratedError checks if an error is of type NoObjectGeneratedError. func IsNoObjectGeneratedError(err error) bool { - var target *NoObjectGeneratedError - return errors.As(err, &target) + _, ok := errors.AsType[*NoObjectGeneratedError](err) + return ok } diff --git a/errors_http2_live_test.go b/errors_http2_live_test.go new file mode 100644 index 000000000..b64ae72c7 --- /dev/null +++ b/errors_http2_live_test.go @@ -0,0 +1,144 @@ +package fantasy + +import ( + "context" + "errors" + "io" + "net" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" +) + +// TestLostPingIsClassifiedLive pins http2ConnectionLostMessages to the +// message Go actually produces. The entry is matched as a string, so a +// reword in a future release would silently stop dead connections being +// retryable, and only this test would notice. +func TestLostPingIsClassifiedLive(t *testing.T) { + t.Parallel() + + // Streams a chunk so the response is live, then holds the stream open + // so the freeze lands mid-stream, where a real one would. + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("data: hello\n\n")) + w.(http.Flusher).Flush() + <-r.Context().Done() + })) + srv.EnableHTTP2 = true + srv.StartTLS() + defer srv.Close() + + conn := &freezableConn{closed: make(chan struct{}), frozen: make(chan struct{})} + defer conn.Close() + + tr := &http.Transport{ + // A Transport carrying a TLSClientConfig does not negotiate h2 on + // its own, and the ping is h2-only, so without this the test would + // pass over HTTP/1.1 having proved nothing. + ForceAttemptHTTP2: true, + TLSClientConfig: srv.Client().Transport.(*http.Transport).TLSClientConfig.Clone(), + HTTP2: &http.HTTP2Config{ + SendPingTimeout: 200 * time.Millisecond, + PingTimeout: 200 * time.Millisecond, + }, + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return conn.dial(ctx, network, addr) + }, + } + defer tr.CloseIdleConnections() + + resp, err := (&http.Client{Transport: tr}).Get(srv.URL) + if err != nil { + t.Fatalf("initial request: %v", err) + } + defer resp.Body.Close() + if resp.ProtoMajor != 2 { + t.Fatalf("got %s, want HTTP/2: the ping config only applies to h2", resp.Proto) + } + + buf := make([]byte, 64) + if _, err := resp.Body.Read(buf); err != nil { + t.Fatalf("reading first chunk: %v", err) + } + + conn.freeze() + + read := make(chan error, 1) + go func() { + for { + if _, err := resp.Body.Read(buf); err != nil { + read <- err + return + } + } + }() + + select { + case err := <-read: + if err == nil || errors.Is(err, io.EOF) { + t.Fatalf("expected a transport failure, got %v", err) + } + if !IsTransportError(err) { + t.Fatalf("IsTransportError(%q) = false, so a dead connection is no "+ + "longer retryable. Go reworded it; update http2ConnectionLostMessages.", err) + } + case <-time.After(10 * time.Second): + t.Fatal("the read never returned: no health check noticed the dead peer") + } +} + +// freezableConn is a client connection that goes deaf and mute on freeze +// while staying open, which is what a socket killed by a sleeping laptop +// looks like: the peer is gone, but no FIN or RST ever says so, and only +// the transport's health-check ping can tell that from a slow reply. +type freezableConn struct { + net.Conn + frozen chan struct{} + closed chan struct{} + once sync.Once +} + +func (c *freezableConn) dial(ctx context.Context, network, addr string) (net.Conn, error) { + conn, err := (&net.Dialer{}).DialContext(ctx, network, addr) + c.Conn = conn + return c, err +} + +func (c *freezableConn) freeze() { close(c.frozen) } + +func (c *freezableConn) isFrozen() bool { + select { + case <-c.frozen: + return true + default: + return false + } +} + +// Read parks until teardown once frozen, so no frame arrives to reset the +// transport's read-idle timer and the health check fires. +func (c *freezableConn) Read(p []byte) (int, error) { + if c.isFrozen() { + <-c.closed + return 0, io.EOF + } + return c.Conn.Read(p) +} + +// Write reports success without sending once frozen, so the health-check +// ping never reaches the server and goes unanswered. +func (c *freezableConn) Write(p []byte) (int, error) { + if c.isFrozen() { + return len(p), nil + } + return c.Conn.Write(p) +} + +// Close releases any parked Read and lets the server see EOF, which is +// what ends its handler and unblocks httptest.Server.Close. +func (c *freezableConn) Close() error { + c.once.Do(func() { close(c.closed) }) + return c.Conn.Close() +} diff --git a/errors_test.go b/errors_test.go index aaec57f3d..53520c22b 100644 --- a/errors_test.go +++ b/errors_test.go @@ -1,6 +1,7 @@ package fantasy import ( + "errors" "fmt" "testing" @@ -27,6 +28,10 @@ func TestIsTransportError(t *testing.T) { {"x/net StreamError", http2.StreamError{StreamID: 1, Code: http2.ErrCodeInternal}, true}, {"x/net ConnectionError", http2.ConnectionError(http2.ErrCodeInternal), true}, {"x/net GoAwayError", http2.GoAwayError{LastStreamID: 1, ErrCode: http2.ErrCodeInternal}, true}, + {"lost health-check ping", newTestError("http2: client connection lost"), true}, + {"wrapped lost ping", fmt.Errorf("reading body: %w", newTestError("http2: client connection lost")), true}, + {"stdlib GoAwayError", newTestError(`http2: server sent GOAWAY and closed the connection; LastStreamID=5, ErrCode=NO_ERROR, debug=""`), true}, + {"deliberate close is not transient", newTestError("http2: client connection force closed via ClientConn.Close"), false}, } for _, tt := range tests { @@ -111,3 +116,72 @@ type testError struct{ msg string } func (e *testError) Error() string { return e.msg } func newTestError(msg string) error { return &testError{msg: msg} } + +func TestExtractHTTP2ErrorMessage(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err error + want string + }{ + { + "lost ping drops the http2 prefix", + newTestError("http2: client connection lost"), + "client connection lost", + }, + { + // The trailing "; LastStreamID=…" reads like the "; CODE" of a + // stream error, so cleaning this one would return the frame + // bookkeeping and discard the part that says what happened. + "GOAWAY keeps the sentence, not the frame detail", + newTestError(`http2: server sent GOAWAY and closed the connection; LastStreamID=5, ErrCode=NO_ERROR, debug=""`), + "server sent GOAWAY and closed the connection", + }, + { + "stream errors still clean", + newTestError("stream error: stream ID 27; INTERNAL_ERROR; received from peer"), + "INTERNAL_ERROR (received from peer)", + }, + { + "unrecognised errors pass through", + newTestError("something went wrong"), + "something went wrong", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := extractHTTP2ErrorMessage(tt.err); got != tt.want { + t.Errorf("extractHTTP2ErrorMessage(%v) = %q, want %q", tt.err, got, tt.want) + } + }) + } +} + +// TestConnectionLostIsRetryable covers the path the retry middleware walks. +// A lost connection reaches it as a bare error that is not a net.Error and +// carries no type to match, so without the message check it reads as +// permanent and the request is never retried. +func TestConnectionLostIsRetryable(t *testing.T) { + t.Parallel() + + // Spelled out rather than ranged over http2ConnectionLostMessages, so + // emptying that list fails here instead of passing vacuously. + for _, msg := range []string{ + "http2: client connection lost", + "http2: server sent GOAWAY and closed the connection", + } { + t.Run(msg, func(t *testing.T) { + t.Parallel() + err := newTestError(msg) + if !isRetryableError(err) { + t.Error("expected a lost connection to be retryable") + } + if wrapped := WrapTransportError(err); !errors.Is(wrapped, err) { + t.Errorf("WrapTransportError dropped the cause: %v", wrapped) + } + }) + } +} diff --git a/examples/computer-use/main.go b/examples/computer-use/main.go index d36a7bdcd..cf6dab46d 100644 --- a/examples/computer-use/main.go +++ b/examples/computer-use/main.go @@ -82,7 +82,8 @@ func main() { // the tool-call loop: it sends the prompt, executes any tool // calls the model returns, feeds the results back, and repeats // until the model stops requesting tools. - agent := fantasy.NewAgent(model, + agent := fantasy.NewAgent( + model, fantasy.WithProviderDefinedTools(computerTool), fantasy.WithStopConditions(fantasy.StepCountIs(10)), ) diff --git a/examples/go.mod b/examples/go.mod index dc159aed4..1ffe7aac2 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -12,37 +12,36 @@ require ( ) require ( - cel.dev/expr v0.25.2 // indirect + cel.dev/expr v0.25.3 // indirect cloud.google.com/go v0.123.0 // indirect - cloud.google.com/go/auth v0.22.0 // indirect - cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect - cloud.google.com/go/compute/metadata v0.9.0 // indirect - cloud.google.com/go/iam v1.12.0 // indirect - cloud.google.com/go/monitoring v1.30.0 // indirect - cloud.google.com/go/storage v1.64.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.35.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.59.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.59.0 // indirect - github.com/ardanlabs/jinja v1.5.0 // indirect - github.com/ardanlabs/kronk v1.29.9 // indirect - github.com/aws/aws-sdk-go-v2 v1.43.2 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.15 // indirect - github.com/aws/aws-sdk-go-v2/config v1.32.33 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.32 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.33 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.33 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.33 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.34 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.14 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.26 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.33 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.34 // indirect - github.com/aws/aws-sdk-go-v2/service/s3 v1.106.2 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.5.2 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.33.2 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.2 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.45.2 // indirect - github.com/aws/smithy-go v1.27.5 // indirect + cloud.google.com/go/auth v0.24.1 // indirect + cloud.google.com/go/auth/oauth2adapt v0.3.0 // indirect + cloud.google.com/go/compute/metadata v0.10.0 // indirect + cloud.google.com/go/iam v1.14.0 // indirect + cloud.google.com/go/monitoring v1.31.0 // indirect + cloud.google.com/go/storage v1.69.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.62.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.62.0 // indirect + github.com/ardanlabs/jinja v1.8.0 // indirect + github.com/ardanlabs/kronk v1.31.5 // indirect + github.com/aws/aws-sdk-go-v2 v1.47.3 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.22 // indirect + github.com/aws/aws-sdk-go-v2/config v1.33.9 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.20.9 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.3 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.6 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.6 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.7 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.6 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.114.3 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.10.4 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.38.4 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.4 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.51.4 // indirect + github.com/aws/smithy-go v1.28.6 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect @@ -51,15 +50,15 @@ require ( github.com/charmbracelet/colorprofile v0.3.2 // indirect github.com/charmbracelet/x/ansi v0.10.2 // indirect github.com/charmbracelet/x/cellbuf v0.0.14-0.20250811133356-e0c5dbe5ea4a // indirect - github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5 // indirect + github.com/charmbracelet/x/exp/slice v0.1.0 // indirect github.com/charmbracelet/x/exp/strings v0.1.0 // indirect github.com/charmbracelet/x/json v0.2.0 // indirect github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect - github.com/ebitengine/purego v0.10.2 // indirect - github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect + github.com/ebitengine/purego v0.11.2 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.40.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect - github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/go-jose/go-jose/v4 v4.1.6 // indirect github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/go-logr/logr v1.4.4 // indirect @@ -67,67 +66,68 @@ require ( github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/s2a-go v0.1.9 // indirect + github.com/google/s2a-go v0.1.11 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.19 // indirect - github.com/googleapis/gax-go/v2 v2.23.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.23 // indirect + github.com/googleapis/gax-go/v2 v2.27.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect - github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.74 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.32.0 // indirect + github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.76 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect - github.com/hashicorp/go-getter v1.8.6 // indirect + github.com/hashicorp/go-getter v1.8.10 // indirect github.com/hashicorp/go-version v1.9.0 // indirect - github.com/hybridgroup/yzma v1.21.0 // indirect - github.com/jupiterrider/ffi v0.7.0 // indirect - github.com/kaptinlin/jsonpointer v0.4.27 // indirect - github.com/kaptinlin/jsonschema v0.9.3 // indirect - github.com/klauspost/compress v1.19.1 // indirect + github.com/hybridgroup/yzma v1.23.0 // indirect + github.com/jupiterrider/ffi v0.8.0 // indirect + github.com/kaptinlin/jsonpointer v0.4.28 // indirect + github.com/kaptinlin/jsonschema v0.9.8 // indirect + github.com/klauspost/compress v1.20.3 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-runewidth v0.0.17 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/openai/openai-go/v3 v3.46.0 // indirect + github.com/openai/openai-go/v3 v3.50.0 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect - github.com/prometheus/client_golang v1.24.1 // indirect - github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.70.1 // indirect - github.com/prometheus/procfs v0.21.1 // indirect + github.com/prometheus/client_golang v1.25.0 // indirect + github.com/prometheus/client_model v0.6.3 // indirect + github.com/prometheus/common v0.72.1 // indirect + github.com/prometheus/procfs v0.22.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect + github.com/spiffe/go-spiffe/v2 v2.9.0 // indirect github.com/tidwall/gjson v1.19.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect - github.com/ulikunitz/xz v0.5.16 // indirect + github.com/ulikunitz/xz v0.5.17 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect - go.opentelemetry.io/otel v1.44.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect - go.opentelemetry.io/otel/metric v1.44.0 // indirect - go.opentelemetry.io/otel/sdk v1.44.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect - go.opentelemetry.io/otel/trace v1.44.0 // indirect - go.opentelemetry.io/proto/otlp v1.11.0 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.47.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.72.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0 // indirect + go.opentelemetry.io/otel v1.47.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0 // indirect + go.opentelemetry.io/otel/log v1.47.0 // indirect + go.opentelemetry.io/otel/metric v1.47.0 // indirect + go.opentelemetry.io/otel/sdk v1.47.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.47.0 // indirect + go.opentelemetry.io/otel/trace v1.47.0 // indirect + go.opentelemetry.io/proto/otlp v1.11.1 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect - golang.org/x/crypto v0.54.0 // indirect - golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect - golang.org/x/image v0.44.0 // indirect - golang.org/x/net v0.57.0 // indirect - golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sync v0.22.0 // indirect - golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.40.0 // indirect - golang.org/x/time v0.15.0 // indirect - google.golang.org/api v0.291.0 // indirect - google.golang.org/genai v1.65.0 // indirect - google.golang.org/genproto v0.0.0-20260729162451-8efbd57d26e0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260729162451-8efbd57d26e0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0 // indirect - google.golang.org/grpc v1.83.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect + golang.org/x/crypto v0.58.0 // indirect + golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e // indirect + golang.org/x/image v0.47.0 // indirect + golang.org/x/net v0.61.0 // indirect + golang.org/x/oauth2 v0.37.0 // indirect + golang.org/x/sync v0.24.0 // indirect + golang.org/x/sys v0.49.0 // indirect + golang.org/x/text v0.43.0 // indirect + golang.org/x/time v0.16.0 // indirect + google.golang.org/api v0.307.0 // indirect + google.golang.org/genai v1.71.0 // indirect + google.golang.org/genproto v0.0.0-20261019180522-ce908d29299b // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b // indirect + google.golang.org/grpc v1.84.0 // indirect + google.golang.org/protobuf v1.36.12 // indirect ) diff --git a/examples/go.sum b/examples/go.sum index 497d05d47..4705b3df0 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -1,73 +1,71 @@ -cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= -cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cel.dev/expr v0.25.3 h1:A2jO8jwOugrrovveCWfj0KEZOfqiLgAcwjpHPhzIGw0= +cel.dev/expr v0.25.3/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= -cloud.google.com/go/auth v0.22.0 h1:Xp9wAKkLoeaYb5pYZZoQGz4E9sdPxIbzS3gywZE3ciQ= -cloud.google.com/go/auth v0.22.0/go.mod h1:M9o2Oz+YI2jAfxewJgb1vyI3vceHF+eohmxyzmrl+9s= -cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= -cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= -cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= -cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= -cloud.google.com/go/iam v1.12.0 h1:Aki3bX9aHUDKPHfnRJfDcTdVedvy6quGBQcTqx3DRXk= -cloud.google.com/go/iam v1.12.0/go.mod h1:FEZ4lXpADAC2AIpQY7LANNjjwyQ2jK439CI2VaD+sLY= -cloud.google.com/go/logging v1.19.0 h1:NCqhdVUg3wQ8Cobdf16FDSuTGi3+6+hdSBHrY5TsR6Q= -cloud.google.com/go/logging v1.19.0/go.mod h1:i40NZCHC9Gqvod4yE+yQfDWwlgwW/SrshkkGibCHxcA= -cloud.google.com/go/longrunning v1.2.0 h1:WjYH3YHBGCxGJP9M4dWGHBfXr/cFIjMkNgWcJj7/iMM= -cloud.google.com/go/longrunning v1.2.0/go.mod h1:5KMQALFGOCtFoi2xSOA1u3H7WKlhmckgiyFw7+LGQp0= -cloud.google.com/go/monitoring v1.30.0 h1:r/d+JUbyKmJ8b07iznuKfzVzrIXTWxHQ3lBRm3x2LlY= -cloud.google.com/go/monitoring v1.30.0/go.mod h1:htlUR0QWVMrjFzZmN4LGnMAve9xB/eduwjmINxVZ8RM= -cloud.google.com/go/storage v1.64.0 h1:KLpxI/oX9LxeRsNqn877d2WyeT3ryiEwnGt8pwcSPZg= -cloud.google.com/go/storage v1.64.0/go.mod h1:lWyAtwvDZHdL3k68WVKbESP6bmWaV23ZJJ/JEVw/ZaQ= +cloud.google.com/go/auth v0.24.1 h1:NTmd2iJb4DsGIav0KiqFMYI88nMshA2wnFMBatRmoCs= +cloud.google.com/go/auth v0.24.1/go.mod h1:1dmBXa9xfBvBr39ZnDUDkaaA91ccq9CcqENDgJA4d7c= +cloud.google.com/go/auth/oauth2adapt v0.3.0 h1:FY8oSZpCYoUNv6QxVODuMjQz4IlSOVeiQtZ08vLPz88= +cloud.google.com/go/auth/oauth2adapt v0.3.0/go.mod h1:7+2uCm7++XFO+/lN06c2HXpDXb/NMNn2/UwyBPbTnkk= +cloud.google.com/go/compute/metadata v0.10.0 h1:pyKMUQSwchgkIBBJGdILqQbs/BNJXqwSA7Ej6LAvvtY= +cloud.google.com/go/compute/metadata v0.10.0/go.mod h1:rGFHRrIif570kSibjFTMbt6/4/tzgJWFGI/HVol4GIk= +cloud.google.com/go/iam v1.14.0 h1:YRIB1/4xxwJ+NfI6RQr1WaOoHHeV0MizJEj940nBXJA= +cloud.google.com/go/iam v1.14.0/go.mod h1:JTPZfbHdGXJrOYREtkRoFzCDGaBdbA7ILLKO/DDuav0= +cloud.google.com/go/logging v1.20.0 h1:l7yzCB8/JSY5PHT9RsaqdT1jaBGbM6KlSJDX210HlXM= +cloud.google.com/go/logging v1.20.0/go.mod h1:ALUaJQZM691Eeujy9kFx0xREq3AMPuGd+pTRZuTYv+w= +cloud.google.com/go/longrunning v1.3.0 h1:lZ6c8SJJBmILp/Wnr3vGnBLa0q+cYfSHRCcUfVI2hpI= +cloud.google.com/go/longrunning v1.3.0/go.mod h1:wpgLxCn+UA3/TZ1bj/fW39mfDpwVlMPu1xKJyoMt7V0= +cloud.google.com/go/monitoring v1.31.0 h1:LCuAoYiq8snyL/eDAG/VC73uWFXeax8+YEfmoNGJUrY= +cloud.google.com/go/monitoring v1.31.0/go.mod h1:7uk2dIJIf7ra64dI1VuQ0ttoFqvtKjdzq46u6zNP1hU= +cloud.google.com/go/storage v1.69.0 h1:jAAMC1411HEh78nKsU0Zns+eFj3TnhjAWIhg5Ud/XBM= +cloud.google.com/go/storage v1.69.0/go.mod h1:PELYsxTYm2peE4mwLEC1+mS1dA/kUSRUxNv56rOy44g= cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E= cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.35.0 h1:bN1gA3of5bXtbnLsRPrwfmbbe7A5UWFlcTHseujLnpc= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.35.0/go.mod h1:Yj5vHEz/aAepZGliRJsA6uvHAVAQyEwajq9ORCHPxzM= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.59.0 h1:c/Ivw7FuawPLfrr+zB0LZKeCchO2cAHQpF2qZ6OV7rQ= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.59.0/go.mod h1:Zba7lknY/d78oxbKqFTmCsaGwfpzeJ3ktrrLXtnTV6g= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.59.0 h1:xTXsqDOj5k9mK3VVWHYUryryJCIdYfXxdjKFwpzINUw= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.59.0/go.mod h1:V9g30lTKzfUsEW+gpWssck6u9IhARajmipodImLLcwI= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.59.0 h1:18FRm6ZcN/x9+ZmhMr96hLcTtlLn2/gHPuDLVeg7XcY= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.59.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk= -github.com/ardanlabs/jinja v1.5.0 h1:OkNpmq9fXiCDRHMLCsd+VO/uk8L/tVwNJE+yA3JAhhM= -github.com/ardanlabs/jinja v1.5.0/go.mod h1:aXXzlJfjA+T3XNKA/YT5ZtDq2VJxt5a5siZ8cl9B35Q= -github.com/ardanlabs/kronk v1.29.9 h1:ThmbRCKSXGLl68tOHCMp5gNBCmyEV1Syi66aP+HMoxo= -github.com/ardanlabs/kronk v1.29.9/go.mod h1:419Te3wIWthLXzUvkP0LIh6FVA9Hh1JsCKA2XY+M3jU= -github.com/aws/aws-sdk-go-v2 v1.43.2 h1:cl+IXwWb3qazClUcm08tGSsB6OiuV83JVJO9B0jQcPc= -github.com/aws/aws-sdk-go-v2 v1.43.2/go.mod h1:WEzLKBh/mEjXvx1FtQMWgSxMSTVqxQzjkRtk5fa3wkg= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.15 h1:rq/p1VNFfygoKEQ9hHMKsKBE98lspPvT8IxaFs5mFhw= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.15/go.mod h1:bELIhlPfW8OkpDhP1MvCjHDtvv8NhiBTz+K4o26zrXA= -github.com/aws/aws-sdk-go-v2/config v1.32.33 h1:M1m/Q6f0OKDEDGwhiNOqx1OjTdrewe3v+GDbHmKczWk= -github.com/aws/aws-sdk-go-v2/config v1.32.33/go.mod h1:fGj1iQj2QpIZzp7jE4aQQ+71TE8cd4z9K4+xCd6EqmE= -github.com/aws/aws-sdk-go-v2/credentials v1.19.32 h1:eNE0JnIblBo1NCvd3tqEYuZz9XDefn69R74CHd3nT7U= -github.com/aws/aws-sdk-go-v2/credentials v1.19.32/go.mod h1:yYJu+6tqKUYZuJSYcpSGjz/6sV/SUaAaKIufnWKx2OU= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.33 h1:MobhiR6KIerWxmO74Zit5I3379+mSc2DOdZ3DeRFB9w= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.33/go.mod h1:xu02847OdZfNr/jAfZpHtyRk0b3v4d0kaoxNHxZGG/w= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.33 h1:HAp1wLFZzch054uh3FK7rcVYg4v7J2FxVf3h3IGNZas= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.33/go.mod h1:mJk5fmqnF+WUlMdPG37pR2Fh3oh6r8F6ZGUgPKvzu0c= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.33 h1:0YA0aCKgsJyno6xkFfaIgjE3/wK08+Qxo9nQfe1UrWM= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.33/go.mod h1:UZqj4WIdTH+ga8Y/DgpAuy/8cGjM3h7gDCliJYGg2SE= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.34 h1:HQYnjFnXpX8EbPW5M1QT8mXzesRPwly0HEPTcFlS02Y= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.34/go.mod h1:tGzj56niKYZBbDIRhwPGDqrULzmWv5b6uBQGqyNaFZw= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.14 h1:SA43nfaY7+1jjMNIc2ywu99JLJLButtIdLP6j+bT870= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.14/go.mod h1:Du3llKcwbQvHsTXSLzTOGQz0DTDBMEzdg7DAGu7inrY= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.26 h1:Eflerh7atY6HN0yz60peNLOkJA2ZKUyYjZexMbqwMCE= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.26/go.mod h1:dCAXNDmik9NuTjfsvCvW22S6ZFpxmtoliFoQu5XFkh8= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.33 h1:mqI7OrxN/DUH85F5OqVn3cIfuZ3+HVcebUm2N8mLlgQ= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.33/go.mod h1:eZ5jdEpvaaOU8nWWE4cTAJETSEA5FZoWxvNRao4piHY= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.34 h1:Lercr2QB2rOrCwyOusmnQ7IiopfkGcZAgMJbjcSdK/s= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.34/go.mod h1:W0xXPPCb2HAqa3cp2f/nRvE+jGgBmchiuXrfBRlfb1I= -github.com/aws/aws-sdk-go-v2/service/s3 v1.106.2 h1:lFSYDEyC1JHucMH3fdczMTnDaghqNttyRXKM8JY9EJQ= -github.com/aws/aws-sdk-go-v2/service/s3 v1.106.2/go.mod h1:aw1E7RCjxs5Sd8N6WdICMcMroff12Tzxte+ELXXNqRU= -github.com/aws/aws-sdk-go-v2/service/signin v1.5.2 h1:EjI1CZzDcBxPkTa3j1BdtIrUDbqnOGssFMeyUS+6W0I= -github.com/aws/aws-sdk-go-v2/service/signin v1.5.2/go.mod h1:vN3eb5H8MEAZ4dx0F5Wc9LT8eb3eW7bZZ5BjGJdbw9k= -github.com/aws/aws-sdk-go-v2/service/sso v1.33.2 h1:zMP1FDFE08L7sM5f1QqkH/ZgKKg8Uc0Dz7KhSSYqWkw= -github.com/aws/aws-sdk-go-v2/service/sso v1.33.2/go.mod h1:0LoIZSUKjdo2BleHfT1hv/jlD33LQS00IrBlzoUsoUQ= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.2 h1:9eTqUYl+SyVmaRPMyBXSO9wwqC6TRwZB82pKENK2hdQ= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.2/go.mod h1:DThweuz22kiLc7lGHop5vQ9c3bx5W6Azs/YqSHa2fu8= -github.com/aws/aws-sdk-go-v2/service/sts v1.45.2 h1:EJd8vZO3E8SE6nmPqxuxlQ1NeSb8as50sf6eGdV4Saw= -github.com/aws/aws-sdk-go-v2/service/sts v1.45.2/go.mod h1:OgpPvKzsO2Ranjpli/20djMkg6UrV5mw4W3pZpq1Mqo= -github.com/aws/smithy-go v1.27.5 h1:d1ro7KpYOYwP6m73YFa+Kc/A130VsAdX68SpsJwARMM= -github.com/aws/smithy-go v1.27.5/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.62.0 h1:5y2LR2pripbccOte6BpZ+guv5O5W0OQIfKvUYZrGaAQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.62.0/go.mod h1:b3f7EifFRqkVt142USoGIP62uUeZm7ALCPrs1ZjWXYo= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.62.0 h1:tRsXhJnn0w0TNwJpVrB94wsrimzjWKFzdQlUhRdNUzc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.62.0/go.mod h1:ifhe5teRNcbxg9p353DhOkOgczzL/IBLYFqT1cm1zpM= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.62.0 h1:aPMa5qZU9dp4fU34o+jQgVIPFIOi20oxYg8TLigmZLg= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.62.0/go.mod h1:sg6Wqbl0BKKZ5zKnQj2q0qic/3ziF894SCx1LpbX37Y= +github.com/ardanlabs/jinja v1.8.0 h1:NuRapYr/hv8J+4v1KymVAbbu6vuqMu+poXkJHfXqLs4= +github.com/ardanlabs/jinja v1.8.0/go.mod h1:VE5Fa3Zglc+e4tFaapOnA6BRMBRDI7A2qCd3Jse6oyw= +github.com/ardanlabs/kronk v1.31.5 h1:pU335FU7KOT2Mv4j4e2S+QaAVYumTRYYKgISkzrQT7Q= +github.com/ardanlabs/kronk v1.31.5/go.mod h1:ub5hRgH9xtMTTQOtBTPwzP67fx0dDwZGLwrWDpp+fuQ= +github.com/aws/aws-sdk-go-v2 v1.47.3 h1:HRoT/MkNi3bjO5xF4dCzLzvVmkvRGHj3/cLINDnJejk= +github.com/aws/aws-sdk-go-v2 v1.47.3/go.mod h1:xSl+OSI5ZUw6bSMupWc218zE5Pl+65MCCu3ySWFY99A= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.22 h1:oiEmPz/YMMeh/JvAE8xSxNCIsvWE6lpFe4ZPlyFykQI= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.22/go.mod h1:QWUDuRi9PFjBHIU2iH1Pkldbe7zqLfXJC4wWLbF8nuQ= +github.com/aws/aws-sdk-go-v2/config v1.33.9 h1:Hv6bcQUJVnmuWaKjqD6Ot8sE75NrWsWV20oGT5aWle0= +github.com/aws/aws-sdk-go-v2/config v1.33.9/go.mod h1:ivowmrZNfBQLcg4QkaItgi5wae4Bzot9RzIdbY1hfTg= +github.com/aws/aws-sdk-go-v2/credentials v1.20.9 h1:SNO55PnWhaDafsOcv16lCA0kdoFMl/YlegWoVsU02s0= +github.com/aws/aws-sdk-go-v2/credentials v1.20.9/go.mod h1:+2reOTRtdD3VSBqpRDNwrZIYy8yMCYa7oK3WKTJ/I1I= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.3 h1:gr3b87OLB29jymWPzp/CAyXwQRJVE+sC4Wm0qBIKHsk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.3/go.mod h1:BRjf4L/Yx2hY54o+ZibPbLJyMMeSwqYQXwcJfqW66a8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.6 h1:CqL8NN3p4I0JTwwV5MEoatIl4Y2amZ2X9dRD+0hbRL0= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.6/go.mod h1:exdJ32hMqXl0Wyo6NwqEC5yIhtua3+PDaKE8P29m4qE= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.6 h1:FpBq24TG6WVSObTNhQOv7Q0088rounUUlwn3zlNItkM= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.6/go.mod h1:ONzdPVYC95oBOu0VWNEsKEntcoemP3xVKjT+qWml1s4= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6 h1:Qmn60RWuk9+bd2ddvJJqwf3Cpac3VvGMOdbGemtUhX4= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6/go.mod h1:h/YJhe4AVyQxEUMluph10iqV7mjajH7A9L+BHF5ppis= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21 h1:e6YW5uME30TkrElR+4/xcY7NB6CCj2dcv7vtmGXhfhQ= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21/go.mod h1:obpHAMP693+f3/A8PkLhIqfK1C+NWWn8zXzqD3OH520= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.7 h1:CwvFlKw0uUScfgOMflXSRZa+ayXgOf6wk7lhyMemQYU= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.7/go.mod h1:4SbD8BB+p+QEnG0aX6DoWwrtk9SufyYXRsUv0nve8LE= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6 h1:LHHdobjo5le1sjJdolitV7uVvYg3wkaeM1Ju2fDgeZQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6/go.mod h1:CBGB0evAiaMD3Lk4wPO7LQCcCZLuhzL5Qf/wfEUHQ5w= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.6 h1:mYViDr2azcgxOpRrObZOl631bvavqILoE/L+Kxgbwys= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.6/go.mod h1:OXVdAo9jNhggB+hJRGOyy7WolGTKB6Rg7IIulDeSzPM= +github.com/aws/aws-sdk-go-v2/service/s3 v1.114.3 h1:NyeuyXkZL476uoAowNActm4XJO1QPKf7aassgXHpiqA= +github.com/aws/aws-sdk-go-v2/service/s3 v1.114.3/go.mod h1:GfoBeKMDJecgRUsGf/0RGdx0NJAh81ObTd922h8pQys= +github.com/aws/aws-sdk-go-v2/service/signin v1.10.4 h1:tvpDjuUvgKURf3Yk4hOaGbZ4QdY+070OWcA5d6LUv2A= +github.com/aws/aws-sdk-go-v2/service/signin v1.10.4/go.mod h1:3ibVKg5Ng82Zby8++jELTD2nHxiPk2DJRLEeW/LKp8M= +github.com/aws/aws-sdk-go-v2/service/sso v1.38.4 h1:ChCtagixSFOxltv0o6E3NT8F7ksvZtZGPwUBynO5Nmk= +github.com/aws/aws-sdk-go-v2/service/sso v1.38.4/go.mod h1:+FEO9jrVMpc9oN3CdAxJbtlQEu3z8UddDPSedpeM3PY= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.4 h1:j6c+HA4AoS0EBEjArPjavFEBrucJEryfrQU0TffmACk= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.4/go.mod h1:RhghaXUk9q66VtOQUX1FdPddQuHnqQZzbB0fEYXmPRA= +github.com/aws/aws-sdk-go-v2/service/sts v1.51.4 h1:OKEfbf2tcfsF2pBk/S9QqNpo6alaAtiBs4hysCsBjPw= +github.com/aws/aws-sdk-go-v2/service/sts v1.51.4/go.mod h1:mCd6qGo9b8hTtfjgsmNkFlzwjyce4owd7HDVFnCLXhM= +github.com/aws/smithy-go v1.28.6 h1:Pj+/sUviNYjow8ELRtNwKHZ9ECeF+7pATX3+shjLIrw= +github.com/aws/smithy-go v1.28.6/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -92,8 +90,8 @@ github.com/charmbracelet/x/cellbuf v0.0.14-0.20250811133356-e0c5dbe5ea4a h1:zYSN github.com/charmbracelet/x/cellbuf v0.0.14-0.20250811133356-e0c5dbe5ea4a/go.mod h1:rc2bsPC6MWae3LdOxNO1mOb443NlMrrDL0xEya48NNc= github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5 h1:DTSZxdV9qQagD4iGcAt9RgaRBZtJl01bfKgdLzUzUPI= -github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms= +github.com/charmbracelet/x/exp/slice v0.1.0 h1:m46dwuDf3M5SFYMRhxYkLC6z9WnENGVKbcbJlp13/og= +github.com/charmbracelet/x/exp/slice v0.1.0/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA= github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8= github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ= @@ -102,24 +100,22 @@ github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSg github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= -github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE= -github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitengine/purego v0.11.2 h1:WWiTx8rD8zYdOwSj8O/NiNbsOvSAgJMTlyV1XlbUSeU= +github.com/ebitengine/purego v0.11.2/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= -github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= -github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= +github.com/envoyproxy/go-control-plane/envoy v1.40.0 h1:G4dcl8KE/HVPnZ25BBdvGZWi8JjyeBfQSvm95U5P1Qc= +github.com/envoyproxy/go-control-plane/envoy v1.40.0/go.mod h1:UZVGjwWu2QV8PkGSlXmqWiSgqCGZe16TgIu4jDl3T5w= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= -github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= -github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-jose/go-jose/v4 v4.1.6 h1:sNRkftxFJxwsILiQukqp5z83QBSmntVwmgRY7AqBDO4= +github.com/go-jose/go-jose/v4 v4.1.6/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg= github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= @@ -139,36 +135,36 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= -github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= -github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/s2a-go v0.1.11 h1:7v1V1TraNGP63WptsAkNqr2w8jgm4vM6A6qx6Lsw9iM= +github.com/google/s2a-go v0.1.11/go.mod h1:pz4tyvwXvJLLbyrkh6FW1eS2zPUXMaTmyNhYtyP2tNw= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.19 h1:mMOE7DN2+p76/EdIrmAy9B9bH+yC4563vmnJ34QR8i4= -github.com/googleapis/enterprise-certificate-proxy v0.3.19/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k= -github.com/googleapis/gax-go/v2 v2.23.0 h1:Tchl7qkvE7Ip3y+ztvNufYFvkfqTe7NfLTYGIdJRLuE= -github.com/googleapis/gax-go/v2 v2.23.0/go.mod h1:rBQKOVJCdb8IFEzg+FCwlt1LP/xMDGuqUXhUG+XMXEg= +github.com/googleapis/enterprise-certificate-proxy v0.3.23 h1:J+VGF1wjAM2dU2kKUXZxJdDppRLReWP7KWcclfngQhc= +github.com/googleapis/enterprise-certificate-proxy v0.3.23/go.mod h1:Y8NO/15GNLkrXI3OR5xOFmeU3XLX1sLSdtlXgJ0zmIw= +github.com/googleapis/gax-go/v2 v2.27.0 h1:qe2iQOUzxb3fd6x6ewpIEixPQhJ+0Ihc/RRZhKA/2g0= +github.com/googleapis/gax-go/v2 v2.27.0/go.mod h1:RofiqDw3ehcG4L0H1zO3D2Itpwd3/uS/7wdmbNDF1DE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= -github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.74 h1:mymLUKThnV9wFvogOK8NnsMP9/vlhnjXY98gr2QIGW8= -github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.74/go.mod h1:Bh9qYL8ehmDxSg14Tk8oxFCP90XHfs6NxV1D84884xA= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.32.0 h1:QMILzF8BA7xftE4zVORkRrlv08m12St3csK4RsyRgRs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.32.0/go.mod h1:+ZQ6iUL8+ATyxGDQNuRKpNwJFdGDUi0e7E1QL9MvI7U= +github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.76 h1:lv1kai0y585J8lMNBhyOoVQssXp3pBv5lLeKb38n+BY= +github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.76/go.mod h1:LlNlsklP+YHM+y1x1SNdOgbXIih0fyqpvB8iGik/sDo= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= -github.com/hashicorp/go-getter v1.8.6 h1:9sQboWULaydVphxc4S64oAI4YqpuCk7nPmvbk131ebY= -github.com/hashicorp/go-getter v1.8.6/go.mod h1:nVH12eOV2P58dIiL3rsU6Fh3wLeJEKBOJzhMmzlSWoo= +github.com/hashicorp/go-getter v1.8.10 h1:uufJp+uacA0fSDRxclsHkiE6yUQTKgwv5b25QexgVM8= +github.com/hashicorp/go-getter v1.8.10/go.mod h1:5HyKGCQmT+I3MGAM76vk/fWpktjwxn7xruCUr9CrVuo= github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hybridgroup/yzma v1.21.0 h1:xBgCH9ziielLeexci8eJ/PvnxCzgLktOaoHH5p/ef30= -github.com/hybridgroup/yzma v1.21.0/go.mod h1:wAMtNTjSnE79jTWzaeFSsuraam0TXKCPajMHA5s+OX8= -github.com/jupiterrider/ffi v0.7.0 h1:RKsl6Ascal+3kyAqR5Qcbp83LceQMLc1VZbPfHWoNzs= -github.com/jupiterrider/ffi v0.7.0/go.mod h1:9dauhpOfNqrqk28fxuu0kkdeFtT9Qr4vbfigiuIXN7c= -github.com/kaptinlin/jsonpointer v0.4.27 h1:5FOnhlkqQ4/lvHudaAWS8HJCXjN4yAHSIGl7aPKHI0Q= -github.com/kaptinlin/jsonpointer v0.4.27/go.mod h1:dfub/n58cWS32Dyf3AZsnKblSAgrz9PyOU76GDPpx8Q= -github.com/kaptinlin/jsonschema v0.9.3 h1:uDVd3w4aXwO0tbycblKYvFofhl3hVuE31vOl5JkDXI0= -github.com/kaptinlin/jsonschema v0.9.3/go.mod h1:LvtQ/mO0E1e/3c3DiOWTj05LTeot8cTv7DyQq7eJMQg= -github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= -github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/hybridgroup/yzma v1.23.0 h1:AqwwhnPOS0I4TzRTCuWxXqGK/kY2P+QZ3+qiJOM3Jl0= +github.com/hybridgroup/yzma v1.23.0/go.mod h1:wAMtNTjSnE79jTWzaeFSsuraam0TXKCPajMHA5s+OX8= +github.com/jupiterrider/ffi v0.8.0 h1:IH6lto8OZaRIulQYObtZibII4tq71hXT6Z+KLBcxCcQ= +github.com/jupiterrider/ffi v0.8.0/go.mod h1:9dauhpOfNqrqk28fxuu0kkdeFtT9Qr4vbfigiuIXN7c= +github.com/kaptinlin/jsonpointer v0.4.28 h1:PyxOfdml9PGpxQz+JnNLGsyzQ+Rnm+OVNTGcgbVLqgg= +github.com/kaptinlin/jsonpointer v0.4.28/go.mod h1:JzeIDw1+60bryzOFea9P3zjKg8pfJHd+UIA/zfZ70x8= +github.com/kaptinlin/jsonschema v0.9.8 h1:srpfx2MAm4ecYz0zJLbz6fiW+UXiuv+vGhUqZUIWmrg= +github.com/kaptinlin/jsonschema v0.9.8/go.mod h1:TP7RFmFBNExjBE0b3fsM6RpA/9PeOzfg2g4weXqOrDg= +github.com/klauspost/compress v1.20.3 h1:zWNoFqq/lSuYQRim3dUXlXeA0IqwuZGicdG5ZTfY5j4= +github.com/klauspost/compress v1.20.3/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ= @@ -179,27 +175,25 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/openai/openai-go/v3 v3.46.0 h1:9HzL4DOybwOHAAcsGpMQyELuw0e9OqynJOPV8SH8g5M= -github.com/openai/openai-go/v3 v3.46.0/go.mod h1:b8MgNMpR3lPifYnaOH8XwcG8qRHwhzZxuDgM9lL7h5k= +github.com/openai/openai-go/v3 v3.50.0 h1:CXn+C8a10oQiI5CMyMbCiykhITVhVxhdHX8j3CfLa2U= +github.com/openai/openai-go/v3 v3.50.0/go.mod h1:Ogjo0gDct+Jm7yCqaCjLGQGygeV8xNfNHV1/yKvCji0= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= -github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= -github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= -github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= -github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= -github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= -github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= +github.com/prometheus/client_golang v1.25.0 h1:cDtW1TcFsMi1RJc8khwCKB4VhzwV04aKqbYtNOXuiK0= +github.com/prometheus/client_golang v1.25.0/go.mod h1:j2X/4/kaQHLPnmyEU8zjmkndo1T317VpCIQEml8LTKk= +github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo= +github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM= +github.com/prometheus/common v0.72.1 h1:ZAiQed/nk8IIcsdzb2TlJu23NEUDyDLQ9TXTN2WRK6Y= +github.com/prometheus/common v0.72.1/go.mod h1:1IoBY4IRaJnhs2ESjNzjilP1cJ/P8pimGoomq7zRXrA= +github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics= +github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/spiffe/go-spiffe/v2 v2.8.1 h1:eXZMLsu+3MLEPJyGJkolqtVrteZfQdUpOWj6LTiDl/E= -github.com/spiffe/go-spiffe/v2 v2.8.1/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/spiffe/go-spiffe/v2 v2.9.0 h1:g5qTg3qIP1HXV+GzuyEfSJFVxUCpEP5Ui1OINVJHPB4= +github.com/spiffe/go-spiffe/v2 v2.9.0/go.mod h1:w2CLWKLMTX/PPYUEUPv3ltH0RXsw5S8suwNF46w9/Aw= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= @@ -210,77 +204,79 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -github.com/ulikunitz/xz v0.5.16 h1:ld6NyySjx5lowVKwJvMRLnW5nxKX/xnpSiFYZ/Lxur0= -github.com/ulikunitz/xz v0.5.16/go.mod h1:H9Rt/W6/Qj27PGauhQc6nfCDy7vHpzsOThBSaYDoEhw= +github.com/ulikunitz/xz v0.5.17 h1:flR0y/x1hgM8EGV1AW3Xll6T413G0glV8UfBwR617V4= +github.com/ulikunitz/xz v0.5.17/go.mod h1:H9Rt/W6/Qj27PGauhQc6nfCDy7vHpzsOThBSaYDoEhw= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/detectors/gcp v1.44.0 h1:NmLfL734pJhM0JKaYd2Y28+nY9dPRWYAAbxhRCrKXPw= -go.opentelemetry.io/contrib/detectors/gcp v1.44.0/go.mod h1:tNAsgd8avTGke1+MndXlU5Cru4PQ9Ai/cCNWQv/ZJ/s= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 h1:2yEATaop1/a1I4psnSLgWVPLWwCzkqWakgJy7xTDVy0= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= -go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= -go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 h1:hqxVTu/GtBF+vJ8d1fzW7fRxZFvgoDjWcxwwCaFDYpU= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q= -go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= -go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= -go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= -go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= -go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= -go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= -go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= -go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= -go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= -go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= -go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= +go.opentelemetry.io/contrib/detectors/gcp v1.47.0 h1:Ct6+aaiSO6Nk5SOb6PYcR7iWp+83YF/pL1OJcGqOmbg= +go.opentelemetry.io/contrib/detectors/gcp v1.47.0/go.mod h1:3N259ss0b1AVOs+fYw7lOO0mcUwNqSIpQ+Zitpuqx00= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.72.0 h1:Tq+E65HaNbZTeqcLw6QUhsV+/EIWdkfUH2CQzcrlE5M= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.72.0/go.mod h1:A+EVhDakAj1waJBPhmTszZaSr08ww8MRRKy1nB1rqck= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0 h1:LxwW/9ctSCv+QkE/cLR7M91ZIkXNMqJtEMi1vCw9U8s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0/go.mod h1:tOsftB4SslBwwErVEPaenU2RpThXWPIU8DoJHEC4dyw= +go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM= +go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 h1:julhjPeUH/q/7hinbSdDdqt5h7Zw9YWmRlWRhI0jd54= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0/go.mod h1:Ao2mz688LH/tFf0yMAenidq6k2YNSx6SIY2q6jDACck= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0 h1:UFpxOpYPmMNUtOWhdb+nC1WELIgVf8z9higURrbzYU4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0/go.mod h1:YBGjxe3lt0jtXoEXxGrGhv/jM7oUBUDVK7zhAvNyEjc= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.45.0 h1:dm9iyzn6tioYZtwqaiBSU0TSI8Yu/8dTIbfG0+B49DY= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.45.0/go.mod h1:xAvxYjYK28qvt+yu4BYZ/zMmAjwMXINXD6JiMyeB8iI= +go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI= +go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw= +go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY= +go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU= +go.opentelemetry.io/otel/metric/x v0.69.0 h1:DjRLr15H83v+hCW7JA9NoJvOkYTtmq5YoDRbe9deYpM= +go.opentelemetry.io/otel/metric/x v0.69.0/go.mod h1:uVvsMPMFFyj/HUQfrUnH3JjnOQ1dwFDorgFLRBasM0k= +go.opentelemetry.io/otel/sdk v1.47.0 h1:zWXEr4j2lFefG87TU6Yg8a7ngfohIKFZHKp0Hf5hC6I= +go.opentelemetry.io/otel/sdk v1.47.0/go.mod h1:VUc24kiOeoGsxG8G9ULx3fWKvB7jMhnGE8Oi607lgR0= +go.opentelemetry.io/otel/sdk/metric v1.47.0 h1:lfISg2j93VT6yqdk9OfUaZmw/GfcZqCCV3jdXtsPnKw= +go.opentelemetry.io/otel/sdk/metric v1.47.0/go.mod h1:ypLp+mW1Nt2x+Szt3b5/i1syodyts49lMOwxpDI3VGw= +go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM= +go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0= +go.opentelemetry.io/proto/otlp v1.11.1 h1:SCrPqH9NE5CmBrOqpjNCiT0X5mGwMyGERXdogfR7Yjw= +go.opentelemetry.io/proto/otlp v1.11.1/go.mod h1:wq8W4aSf6bOy8RZyESOeQFr9Pu7wydWX/TY4sgvrTPg= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= -golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= -golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= -golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I= -golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY= -golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= -golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= -golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= -golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= -golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= +golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e h1:HgI6W1OolKK6tcV74is+rl9SMT0njsHlp0TbQiGXI/A= +golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e/go.mod h1:DBoxwWhjaW8DJJw/YeM4zpeOKMC1FMXSblsYy+1mFSw= +golang.org/x/image v0.47.0 h1:za/8gVz++PPZqmZJBNSISICN5Rrj5zixP/8K++fgmqo= +golang.org/x/image v0.47.0/go.mod h1:IkN5o4A7vq55vXN8FWjjnhg9Rs2GOtffqsSi9mWAKYE= +golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= +golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= +golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= +golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.291.0 h1:wfPbbY+mr9c7wZLqqzrHJLft/q8iFKREd6IgTBUene0= -google.golang.org/api v0.291.0/go.mod h1:at7kwWbuonglBFEBoeMDAV1bguHqL3qf0BHFsv3coa0= -google.golang.org/genai v1.65.0 h1:6QK3Rjsx0iuJjbDCE1vf1VUr1IEjRDpvexGGnhxoAIk= -google.golang.org/genai v1.65.0/go.mod h1:mDdPDFXo1Ats7f1WXVyZgWb/CkMzFWTWJruIMy7hGIU= -google.golang.org/genproto v0.0.0-20260729162451-8efbd57d26e0 h1:xJf8e9ReUqiexuIT3OWJkvA4RdgR8K4Hfheq8gaKxIM= -google.golang.org/genproto v0.0.0-20260729162451-8efbd57d26e0/go.mod h1:0MNk3ibJAyOwDZVlp18knQe3jRyFxpcU06xjxhgjx0M= -google.golang.org/genproto/googleapis/api v0.0.0-20260729162451-8efbd57d26e0 h1:ybvH/ZpOcpCrjtkb7oW/fdlzbEmRVeumw19SRQmNFKU= -google.golang.org/genproto/googleapis/api v0.0.0-20260729162451-8efbd57d26e0/go.mod h1:HJ9MpJLeDSstBkx1LILTpd5f41ADSMZcTPypw02qEGw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0 h1:mJiOtnGp0k/BcSgdu03G2NwnscCfCH+h2QKUBZr18KI= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ= -google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/api v0.307.0 h1:KJJv1V/cOeCUrgKFippAhSg98QlR2IZFqubQKf7gQdg= +google.golang.org/api v0.307.0/go.mod h1:4XMH+b/ZEfuTbK7FnrFRY9c4xfFHVflGTKuy/i5wUmE= +google.golang.org/genai v1.71.0 h1:Wfo9n0uSzMhZH7d+rP7QxxSWELEDSD4z6O8W/C9s3oM= +google.golang.org/genai v1.71.0/go.mod h1:mDdPDFXo1Ats7f1WXVyZgWb/CkMzFWTWJruIMy7hGIU= +google.golang.org/genproto v0.0.0-20261019180522-ce908d29299b h1:+t2qGqt6R8CO622uiX1JpnRvRvSOQoVRV0DiPzUjKXg= +google.golang.org/genproto v0.0.0-20261019180522-ce908d29299b/go.mod h1:XRIfc1yr5xsbF8xFw5+g7SEmLJX/eQ7MMazTRbnMMvI= +google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b h1:nB3spRIaSPtxJpxb0k41yljROvm2Jrwfs2pSNdKS5y8= +google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b/go.mod h1:9N4vtjEwvgQKEdlFVQO0M44ErhecQ9DCSQfGoZ51M6w= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b h1:6WNWHZk91dp4gZ25642OALkuG1UVNZbwENJD19DnB8k= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= +google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= +google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/kronk/simple/main.go b/examples/kronk/simple/main.go index 44919fd4e..ed758e65a 100644 --- a/examples/kronk/simple/main.go +++ b/examples/kronk/simple/main.go @@ -27,9 +27,10 @@ func run() error { defer cancel() // Create the provider with optional logging. - provider, err := kronk.New( + provider, err := kronk.NewProvider( kronk.WithName("kronk"), kronk.WithLogger(kronk.FmtLogger), + kronk.WithAutoTune(true), ) if err != nil { return fmt.Errorf("unable to create provider: %w", err) @@ -38,10 +39,8 @@ func run() error { // Clean up when done. defer func() { fmt.Println("\nUnloading Kronk") - if closer, ok := provider.(interface{ Close(context.Context) error }); ok { - if err := closer.Close(context.Background()); err != nil { - fmt.Printf("failed to close provider: %v\n", err) - } + if err := provider.Close(context.Background()); err != nil { + fmt.Printf("failed to close provider: %v\n", err) } }() @@ -71,14 +70,24 @@ func run() error { // Add the tool. cuteDogTool := fantasy.NewAgentTool("cute_dog_tool", "Provide up-to-date info on cute dogs.", fetchCuteDogInfo) + seed := int64(42) + minP := 0.05 + repeatPenalty := 1.1 + // Equip your agent. - agent := fantasy.NewAgent(model, + agent := fantasy.NewAgent( + model, fantasy.WithSystemPrompt("You are a moderately helpful, dog-centric assistant."), fantasy.WithTools(cuteDogTool), fantasy.WithMaxOutputTokens(2048), fantasy.WithTemperature(0.7), fantasy.WithTopP(0.8), fantasy.WithTopK(20), + fantasy.WithProviderOptions(kronk.NewProviderOptions(&kronk.ProviderOptions{ + Seed: &seed, + MinP: &minP, + RepeatPenalty: &repeatPenalty, + })), ) // Put that agent to work! @@ -88,6 +97,7 @@ func run() error { return fmt.Errorf("agent generate failed: %w", err) } fmt.Println(result.Response.Content.Text()) + fmt.Printf("\nUsage: %s\n", result.TotalUsage) return nil } diff --git a/examples/kronk/stream/main.go b/examples/kronk/stream/main.go index e63465919..f21c96364 100644 --- a/examples/kronk/stream/main.go +++ b/examples/kronk/stream/main.go @@ -59,9 +59,10 @@ func run() error { defer cancel() // Create the provider with optional logging. - provider, err := kronk.New( + provider, err := kronk.NewProvider( kronk.WithName("kronk"), kronk.WithLogger(kronk.FmtLogger), + kronk.WithAutoTune(true), ) if err != nil { return fmt.Errorf("unable to create provider: %w", err) @@ -70,10 +71,8 @@ func run() error { // Clean up when done. defer func() { fmt.Println("\nUnloading Kronk") - if closer, ok := provider.(interface{ Close(context.Context) error }); ok { - if err := closer.Close(context.Background()); err != nil { - fmt.Printf("failed to close provider: %v\n", err) - } + if err := provider.Close(context.Background()); err != nil { + fmt.Printf("failed to close provider: %v\n", err) } }() @@ -124,6 +123,12 @@ func run() error { return nil }, + // When the final response starts. + OnTextStart: func(id string) error { + fmt.Print("[Response]\n") + return nil + }, + // When we receive a chunk of streaming data. OnTextDelta: func(id, text string) error { _, fmtErr := fmt.Print(text) @@ -157,11 +162,11 @@ func run() error { fmt.Println("Generating...") // Finally, let's stream everything! - _, err = agent.Stream(ctx, streamCall) + result, err := agent.Stream(ctx, streamCall) if err != nil { - fmt.Fprintf(os.Stderr, "Error generating response: %v\n", err) - os.Exit(1) + return fmt.Errorf("agent stream failed: %w", err) } + fmt.Printf("\nUsage: %s\n", result.TotalUsage) return nil } diff --git a/examples/kronk/vision/main.go b/examples/kronk/vision/main.go new file mode 100644 index 000000000..9ed0a441b --- /dev/null +++ b/examples/kronk/vision/main.go @@ -0,0 +1,80 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "charm.land/fantasy" + "charm.land/fantasy/providers/kronk" +) + +const modelURL = "unsloth/Qwen3.5-0.8B-GGUF/Qwen3.5-0.8B-Q8_0.gguf" + +func main() { + if err := run(); err != nil { + fmt.Printf("\nERROR: %s\n", err) + os.Exit(1) + } +} + +func run() error { + if len(os.Args) != 2 { + return fmt.Errorf("usage: go run ./kronk/vision ") + } + + image, err := os.ReadFile(os.Args[1]) + if err != nil { + return fmt.Errorf("reading image: %w", err) + } + mediaType := http.DetectContentType(image) + if !strings.HasPrefix(mediaType, "image/") { + return fmt.Errorf("unsupported image type %q", mediaType) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + provider, err := kronk.NewProvider( + kronk.WithLogger(kronk.FmtLogger), + kronk.WithAutoTune(true), + ) + if err != nil { + return fmt.Errorf("creating provider: %w", err) + } + defer func() { + fmt.Println("\nUnloading Kronk") + if err := provider.Close(context.Background()); err != nil { + fmt.Printf("failed to close provider: %v\n", err) + } + }() + + languageModel, err := provider.LanguageModel(ctx, modelURL) + if err != nil { + return fmt.Errorf("loading language model: %w", err) + } + + agent := fantasy.NewAgent(languageModel) + result, err := agent.Generate(ctx, fantasy.AgentCall{ + Prompt: "Describe this image briefly.", + Files: []fantasy.FilePart{ + { + Filename: filepath.Base(os.Args[1]), + Data: image, + MediaType: mediaType, + }, + }, + }) + if err != nil { + return fmt.Errorf("generating description: %w", err) + } + + fmt.Println(result.Response.Content.Text()) + fmt.Printf("\nUsage: %s\n", result.TotalUsage) + + return nil +} diff --git a/examples/simple/main.go b/examples/simple/main.go index cc6b0f583..85084d360 100644 --- a/examples/simple/main.go +++ b/examples/simple/main.go @@ -47,7 +47,8 @@ func main() { cuteDogTool := fantasy.NewAgentTool("cute_dog_tool", "Provide up-to-date info on cute dogs.", fetchCuteDogInfo) // Equip your agent. - agent := fantasy.NewAgent(model, + agent := fantasy.NewAgent( + model, fantasy.WithSystemPrompt("You are a moderately helpful, dog-centric assistant."), fantasy.WithTools(cuteDogTool), ) diff --git a/examples/web-search/main.go b/examples/web-search/main.go index 203e46ad7..8f9487174 100644 --- a/examples/web-search/main.go +++ b/examples/web-search/main.go @@ -52,7 +52,8 @@ func main() { os.Exit(1) } - agent := fantasy.NewAgent(model, + agent := fantasy.NewAgent( + model, fantasy.WithProviderDefinedTools(webSearch), ) diff --git a/go.mod b/go.mod index bef7b594d..ed1728602 100644 --- a/go.mod +++ b/go.mod @@ -4,13 +4,13 @@ go 1.26.5 require ( charm.land/x/vcr v0.1.1 - cloud.google.com/go/auth v0.22.0 - github.com/ardanlabs/kronk v1.29.9 - github.com/aws/aws-sdk-go-v2 v1.43.4 - github.com/aws/aws-sdk-go-v2/config v1.32.35 - github.com/aws/smithy-go v1.27.6 + cloud.google.com/go/auth v0.24.1 + github.com/ardanlabs/kronk v1.31.5 + github.com/aws/aws-sdk-go-v2 v1.47.3 + github.com/aws/aws-sdk-go-v2/config v1.33.9 + github.com/aws/smithy-go v1.28.6 github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab - github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5 + github.com/charmbracelet/x/exp/slice v0.1.0 github.com/charmbracelet/x/exp/strings v0.1.0 github.com/charmbracelet/x/json v0.2.0 github.com/go-viper/mapstructure/v2 v2.5.0 @@ -18,112 +18,108 @@ require ( github.com/joho/godotenv v1.5.1 github.com/kaptinlin/jsonschema v0.9.8 github.com/openai/openai-go/v3 v3.50.0 - github.com/stretchr/testify v1.11.1 - golang.org/x/net v0.57.0 - golang.org/x/oauth2 v0.36.0 - google.golang.org/genai v1.67.0 + github.com/stretchr/testify v1.12.1 + golang.org/x/net v0.61.0 + golang.org/x/oauth2 v0.37.0 + google.golang.org/genai v1.71.0 ) require ( - cel.dev/expr v0.25.2 // indirect + cel.dev/expr v0.25.3 // indirect cloud.google.com/go v0.123.0 // indirect - cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect - cloud.google.com/go/compute/metadata v0.9.0 // indirect - cloud.google.com/go/iam v1.12.0 // indirect - cloud.google.com/go/monitoring v1.30.0 // indirect - cloud.google.com/go/storage v1.64.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.35.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.59.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.59.0 // indirect - github.com/ardanlabs/jinja v1.5.0 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.15 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.34 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.35 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.26 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.34 // indirect - github.com/aws/aws-sdk-go-v2/service/s3 v1.106.2 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.5.4 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.33.4 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.45.4 // indirect + cloud.google.com/go/auth/oauth2adapt v0.3.0 // indirect + cloud.google.com/go/compute/metadata v0.10.0 // indirect + cloud.google.com/go/iam v1.14.0 // indirect + cloud.google.com/go/monitoring v1.31.0 // indirect + cloud.google.com/go/storage v1.69.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.62.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.62.0 // indirect + github.com/ardanlabs/jinja v1.8.0 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.22 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.20.9 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.3 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.6 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.6 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.7 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.6 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.114.3 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.10.4 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.38.4 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.4 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.51.4 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/ebitengine/purego v0.10.2 // indirect - github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect + github.com/ebitengine/purego v0.11.2 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.40.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect - github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/go-jose/go-jose/v4 v4.1.6 // indirect github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/s2a-go v0.1.9 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.19 // indirect - github.com/googleapis/gax-go/v2 v2.23.0 // indirect + github.com/google/s2a-go v0.1.11 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.23 // indirect + github.com/googleapis/gax-go/v2 v2.27.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect - github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.74 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.32.0 // indirect + github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.76 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect - github.com/hashicorp/go-getter v1.8.6 // indirect + github.com/hashicorp/go-getter v1.8.10 // indirect github.com/hashicorp/go-version v1.9.0 // indirect - github.com/hybridgroup/yzma v1.21.0 // indirect - github.com/jupiterrider/ffi v0.7.0 // indirect + github.com/hybridgroup/yzma v1.23.0 // indirect + github.com/jupiterrider/ffi v0.8.0 // indirect github.com/kaptinlin/jsonpointer v0.4.28 // indirect - github.com/klauspost/compress v1.19.1 // indirect + github.com/klauspost/compress v1.20.3 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/client_golang v1.24.1 // indirect - github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.70.1 // indirect - github.com/prometheus/procfs v0.21.1 // indirect - github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect + github.com/prometheus/client_golang v1.25.0 // indirect + github.com/prometheus/client_model v0.6.3 // indirect + github.com/prometheus/common v0.72.1 // indirect + github.com/prometheus/procfs v0.22.0 // indirect + github.com/spiffe/go-spiffe/v2 v2.9.0 // indirect github.com/tidwall/gjson v1.19.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect - github.com/ulikunitz/xz v0.5.16 // indirect + github.com/ulikunitz/xz v0.5.17 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect - go.opentelemetry.io/otel v1.44.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect - go.opentelemetry.io/otel/metric v1.44.0 // indirect - go.opentelemetry.io/otel/sdk v1.44.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect - go.opentelemetry.io/otel/trace v1.44.0 // indirect - go.opentelemetry.io/proto/otlp v1.11.0 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.47.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.72.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0 // indirect + go.opentelemetry.io/otel v1.47.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0 // indirect + go.opentelemetry.io/otel/log v1.47.0 // indirect + go.opentelemetry.io/otel/metric v1.47.0 // indirect + go.opentelemetry.io/otel/sdk v1.47.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.47.0 // indirect + go.opentelemetry.io/otel/trace v1.47.0 // indirect + go.opentelemetry.io/proto/otlp v1.11.1 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect - golang.org/x/crypto v0.54.0 // indirect - golang.org/x/image v0.45.0 // indirect - golang.org/x/sync v0.22.0 // indirect - golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.41.0 // indirect - golang.org/x/time v0.15.0 // indirect - google.golang.org/api v0.291.0 // indirect - google.golang.org/genproto v0.0.0-20260729162451-8efbd57d26e0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260729162451-8efbd57d26e0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0 // indirect - google.golang.org/grpc v1.83.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect + golang.org/x/crypto v0.58.0 // indirect + golang.org/x/image v0.47.0 // indirect + golang.org/x/sync v0.24.0 // indirect + golang.org/x/sys v0.49.0 // indirect + golang.org/x/text v0.43.0 // indirect + golang.org/x/time v0.16.0 // indirect + google.golang.org/api v0.307.0 // indirect + google.golang.org/genproto v0.0.0-20261019180522-ce908d29299b // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b // indirect + google.golang.org/grpc v1.84.0 // indirect + google.golang.org/protobuf v1.36.12 // indirect gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) // coder/coder uses a fork of charmbracelet's fork of the Anthropic Go SDK with some diff --git a/go.sum b/go.sum index 69e526000..88a17c5c9 100644 --- a/go.sum +++ b/go.sum @@ -1,83 +1,73 @@ -cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= -cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cel.dev/expr v0.25.3 h1:A2jO8jwOugrrovveCWfj0KEZOfqiLgAcwjpHPhzIGw0= +cel.dev/expr v0.25.3/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= charm.land/x/vcr v0.1.1 h1:PXCFMUG0rPtyk35rhfzYCJEduOzWXCIbrXTFq4OF/9Q= charm.land/x/vcr v0.1.1/go.mod h1:eByq2gqzWvcct/8XE2XO5KznoWEBiXH56+y2gphbltM= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= -cloud.google.com/go/auth v0.22.0 h1:Xp9wAKkLoeaYb5pYZZoQGz4E9sdPxIbzS3gywZE3ciQ= -cloud.google.com/go/auth v0.22.0/go.mod h1:M9o2Oz+YI2jAfxewJgb1vyI3vceHF+eohmxyzmrl+9s= -cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= -cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= -cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= -cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= -cloud.google.com/go/iam v1.12.0 h1:Aki3bX9aHUDKPHfnRJfDcTdVedvy6quGBQcTqx3DRXk= -cloud.google.com/go/iam v1.12.0/go.mod h1:FEZ4lXpADAC2AIpQY7LANNjjwyQ2jK439CI2VaD+sLY= -cloud.google.com/go/logging v1.19.0 h1:NCqhdVUg3wQ8Cobdf16FDSuTGi3+6+hdSBHrY5TsR6Q= -cloud.google.com/go/logging v1.19.0/go.mod h1:i40NZCHC9Gqvod4yE+yQfDWwlgwW/SrshkkGibCHxcA= -cloud.google.com/go/longrunning v1.2.0 h1:WjYH3YHBGCxGJP9M4dWGHBfXr/cFIjMkNgWcJj7/iMM= -cloud.google.com/go/longrunning v1.2.0/go.mod h1:5KMQALFGOCtFoi2xSOA1u3H7WKlhmckgiyFw7+LGQp0= -cloud.google.com/go/monitoring v1.30.0 h1:r/d+JUbyKmJ8b07iznuKfzVzrIXTWxHQ3lBRm3x2LlY= -cloud.google.com/go/monitoring v1.30.0/go.mod h1:htlUR0QWVMrjFzZmN4LGnMAve9xB/eduwjmINxVZ8RM= -cloud.google.com/go/storage v1.64.0 h1:KLpxI/oX9LxeRsNqn877d2WyeT3ryiEwnGt8pwcSPZg= -cloud.google.com/go/storage v1.64.0/go.mod h1:lWyAtwvDZHdL3k68WVKbESP6bmWaV23ZJJ/JEVw/ZaQ= +cloud.google.com/go/auth v0.24.1 h1:NTmd2iJb4DsGIav0KiqFMYI88nMshA2wnFMBatRmoCs= +cloud.google.com/go/auth v0.24.1/go.mod h1:1dmBXa9xfBvBr39ZnDUDkaaA91ccq9CcqENDgJA4d7c= +cloud.google.com/go/auth/oauth2adapt v0.3.0 h1:FY8oSZpCYoUNv6QxVODuMjQz4IlSOVeiQtZ08vLPz88= +cloud.google.com/go/auth/oauth2adapt v0.3.0/go.mod h1:7+2uCm7++XFO+/lN06c2HXpDXb/NMNn2/UwyBPbTnkk= +cloud.google.com/go/compute/metadata v0.10.0 h1:pyKMUQSwchgkIBBJGdILqQbs/BNJXqwSA7Ej6LAvvtY= +cloud.google.com/go/compute/metadata v0.10.0/go.mod h1:rGFHRrIif570kSibjFTMbt6/4/tzgJWFGI/HVol4GIk= +cloud.google.com/go/iam v1.14.0 h1:YRIB1/4xxwJ+NfI6RQr1WaOoHHeV0MizJEj940nBXJA= +cloud.google.com/go/iam v1.14.0/go.mod h1:JTPZfbHdGXJrOYREtkRoFzCDGaBdbA7ILLKO/DDuav0= +cloud.google.com/go/logging v1.20.0 h1:l7yzCB8/JSY5PHT9RsaqdT1jaBGbM6KlSJDX210HlXM= +cloud.google.com/go/logging v1.20.0/go.mod h1:ALUaJQZM691Eeujy9kFx0xREq3AMPuGd+pTRZuTYv+w= +cloud.google.com/go/longrunning v1.3.0 h1:lZ6c8SJJBmILp/Wnr3vGnBLa0q+cYfSHRCcUfVI2hpI= +cloud.google.com/go/longrunning v1.3.0/go.mod h1:wpgLxCn+UA3/TZ1bj/fW39mfDpwVlMPu1xKJyoMt7V0= +cloud.google.com/go/monitoring v1.31.0 h1:LCuAoYiq8snyL/eDAG/VC73uWFXeax8+YEfmoNGJUrY= +cloud.google.com/go/monitoring v1.31.0/go.mod h1:7uk2dIJIf7ra64dI1VuQ0ttoFqvtKjdzq46u6zNP1hU= +cloud.google.com/go/storage v1.69.0 h1:jAAMC1411HEh78nKsU0Zns+eFj3TnhjAWIhg5Ud/XBM= +cloud.google.com/go/storage v1.69.0/go.mod h1:PELYsxTYm2peE4mwLEC1+mS1dA/kUSRUxNv56rOy44g= cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E= cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 h1:aokoqcHvaGjiM3VpjKDfMMnF/8epJ+Q1HLJ7CudztqE= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0/go.mod h1:/WYEx9pcM9Y+Dd/APJaNlSvVSvzl54rrMdZT5+Oi2LM= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 h1:CU4+EJeJi3TKYWEcYuSdWsjzw0nVsK/H0MSQOiPcymU= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0/go.mod h1:q0+UTSRvShwUCrR/s5HtyInYphN7Wvxb7snFM3u+SLA= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= -github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 h1:RHK7bS+HQMslb1sZpAokUt+zTVmue0hKSs2C791hhzU= -github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.35.0 h1:bN1gA3of5bXtbnLsRPrwfmbbe7A5UWFlcTHseujLnpc= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.35.0/go.mod h1:Yj5vHEz/aAepZGliRJsA6uvHAVAQyEwajq9ORCHPxzM= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.59.0 h1:c/Ivw7FuawPLfrr+zB0LZKeCchO2cAHQpF2qZ6OV7rQ= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.59.0/go.mod h1:Zba7lknY/d78oxbKqFTmCsaGwfpzeJ3ktrrLXtnTV6g= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.59.0 h1:xTXsqDOj5k9mK3VVWHYUryryJCIdYfXxdjKFwpzINUw= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.59.0/go.mod h1:V9g30lTKzfUsEW+gpWssck6u9IhARajmipodImLLcwI= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.59.0 h1:18FRm6ZcN/x9+ZmhMr96hLcTtlLn2/gHPuDLVeg7XcY= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.59.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk= -github.com/ardanlabs/jinja v1.5.0 h1:OkNpmq9fXiCDRHMLCsd+VO/uk8L/tVwNJE+yA3JAhhM= -github.com/ardanlabs/jinja v1.5.0/go.mod h1:aXXzlJfjA+T3XNKA/YT5ZtDq2VJxt5a5siZ8cl9B35Q= -github.com/ardanlabs/kronk v1.29.9 h1:ThmbRCKSXGLl68tOHCMp5gNBCmyEV1Syi66aP+HMoxo= -github.com/ardanlabs/kronk v1.29.9/go.mod h1:419Te3wIWthLXzUvkP0LIh6FVA9Hh1JsCKA2XY+M3jU= -github.com/aws/aws-sdk-go-v2 v1.43.4 h1:b9FTvbRwy+JCsfp2Wp6wV/KbOx3Aj7nkoFb2cRX0IhE= -github.com/aws/aws-sdk-go-v2 v1.43.4/go.mod h1:70vwSy16txshwG+g55WkpgPKDIByzHI8ccBsOteo3bQ= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.15 h1:rq/p1VNFfygoKEQ9hHMKsKBE98lspPvT8IxaFs5mFhw= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.15/go.mod h1:bELIhlPfW8OkpDhP1MvCjHDtvv8NhiBTz+K4o26zrXA= -github.com/aws/aws-sdk-go-v2/config v1.32.35 h1:UEzXuET8E42lxBPijuACu/tEK7v5lFPlk0Q+GT5WD9E= -github.com/aws/aws-sdk-go-v2/config v1.32.35/go.mod h1:KaMtJpFa2JlL2BStjjHQVwQpzZEmw+ND/EgVrfFoo2g= -github.com/aws/aws-sdk-go-v2/credentials v1.19.34 h1:y6GkSmcv5myd1ngrYbGmiLlwQqB6TQhOuN/tbSSuWDY= -github.com/aws/aws-sdk-go-v2/credentials v1.19.34/go.mod h1:w3dTcnDVoQIewjo7JG45hduAToikiIFLC4FIO7fndvw= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.35 h1:+S7kbJoLDDQ5tE+lHrUBgMkzC8NLgsaioS2F3dVoFAE= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.35/go.mod h1:Ak7xXviIARfFdNUJ9Etb0bdVDt/KAvKjMGJVLWXDzik= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35 h1:kzVuGlatQtYinwBJEEyLAbggepCoavosiaHHX9+fD+c= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35/go.mod h1:0yLx0yEI+SfqeJMPvOtIEFoZbiQYXMGszBueiutQyaI= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35 h1:WK6CjihTuLisCjSKKbildJ79sGZZgbBz3iNa7VsKIhU= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35/go.mod h1:KYleN57luLoe97R7vTnx8PMcVrr9gAcRECtOjl91DNg= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36 h1:jbGY4CXLzZElOXgGsexlC3Hi+3YM0rSmk4opFXKqg/k= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36/go.mod h1:uBu/9aKsS/UQGc72RAt3y54kjgYQxmhut8ZD2dXCDNE= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 h1:JJLBQxwY+AFwuPAi5ivGc1ChnTdUt4cXMv7e76m2c/Y= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15/go.mod h1:lQknBIe78MVL0cQOQDlag8KGflMbMEVFx9mB6O8ENvk= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.26 h1:Eflerh7atY6HN0yz60peNLOkJA2ZKUyYjZexMbqwMCE= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.26/go.mod h1:dCAXNDmik9NuTjfsvCvW22S6ZFpxmtoliFoQu5XFkh8= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35 h1:BBEElKh4a+rKshvjrfpajTe9CbpZvrbb4Jkg2PB7RzA= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35/go.mod h1:zaZk983w//8beSruBVec/mr4CmDwgZitW/qzGhAAX0g= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.34 h1:Lercr2QB2rOrCwyOusmnQ7IiopfkGcZAgMJbjcSdK/s= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.34/go.mod h1:W0xXPPCb2HAqa3cp2f/nRvE+jGgBmchiuXrfBRlfb1I= -github.com/aws/aws-sdk-go-v2/service/s3 v1.106.2 h1:lFSYDEyC1JHucMH3fdczMTnDaghqNttyRXKM8JY9EJQ= -github.com/aws/aws-sdk-go-v2/service/s3 v1.106.2/go.mod h1:aw1E7RCjxs5Sd8N6WdICMcMroff12Tzxte+ELXXNqRU= -github.com/aws/aws-sdk-go-v2/service/signin v1.5.4 h1:cOJELVNrq5Q3Udry2GLuHUM7MhwpeaQRdYaoa6GI/yI= -github.com/aws/aws-sdk-go-v2/service/signin v1.5.4/go.mod h1:f4LxzKBtaTxD7xh3PiVg3CE1tchQemfmghaJr+NbK2c= -github.com/aws/aws-sdk-go-v2/service/sso v1.33.4 h1:AMW7a7S8iQaHjBYZdU3PCq4GKRPijTPRAc7e6XtEThY= -github.com/aws/aws-sdk-go-v2/service/sso v1.33.4/go.mod h1:QQNsFV1DVXoXcZt18FS8lI8rtUrlDyAuWZLQ5shunv4= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4 h1:AsbZcJAQPRmHDJG8K1N0pof/1zPWjVT8TFlTWuGLSvo= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4/go.mod h1:6imqztH0//t0mKbl6yWl7swSEl7F/w32oAmqB3vP1ag= -github.com/aws/aws-sdk-go-v2/service/sts v1.45.4 h1:w/AryDYMjSUANSQ2uoZxJovUsMTwWJNTv3IMex30Y+4= -github.com/aws/aws-sdk-go-v2/service/sts v1.45.4/go.mod h1:WeBiAa67azG7Su9Vf+ChGDBLiAozJCXzdjXiPBUwtbc= -github.com/aws/smithy-go v1.27.6 h1:0zjT8jgK3jbrTT7JJ3EE6JsMhX8JTrZ+f1sEndYDXrA= -github.com/aws/smithy-go v1.27.6/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.62.0 h1:5y2LR2pripbccOte6BpZ+guv5O5W0OQIfKvUYZrGaAQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.62.0/go.mod h1:b3f7EifFRqkVt142USoGIP62uUeZm7ALCPrs1ZjWXYo= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.62.0 h1:tRsXhJnn0w0TNwJpVrB94wsrimzjWKFzdQlUhRdNUzc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.62.0/go.mod h1:ifhe5teRNcbxg9p353DhOkOgczzL/IBLYFqT1cm1zpM= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.62.0 h1:aPMa5qZU9dp4fU34o+jQgVIPFIOi20oxYg8TLigmZLg= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.62.0/go.mod h1:sg6Wqbl0BKKZ5zKnQj2q0qic/3ziF894SCx1LpbX37Y= +github.com/ardanlabs/jinja v1.8.0 h1:NuRapYr/hv8J+4v1KymVAbbu6vuqMu+poXkJHfXqLs4= +github.com/ardanlabs/jinja v1.8.0/go.mod h1:VE5Fa3Zglc+e4tFaapOnA6BRMBRDI7A2qCd3Jse6oyw= +github.com/ardanlabs/kronk v1.31.5 h1:pU335FU7KOT2Mv4j4e2S+QaAVYumTRYYKgISkzrQT7Q= +github.com/ardanlabs/kronk v1.31.5/go.mod h1:ub5hRgH9xtMTTQOtBTPwzP67fx0dDwZGLwrWDpp+fuQ= +github.com/aws/aws-sdk-go-v2 v1.47.3 h1:HRoT/MkNi3bjO5xF4dCzLzvVmkvRGHj3/cLINDnJejk= +github.com/aws/aws-sdk-go-v2 v1.47.3/go.mod h1:xSl+OSI5ZUw6bSMupWc218zE5Pl+65MCCu3ySWFY99A= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.22 h1:oiEmPz/YMMeh/JvAE8xSxNCIsvWE6lpFe4ZPlyFykQI= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.22/go.mod h1:QWUDuRi9PFjBHIU2iH1Pkldbe7zqLfXJC4wWLbF8nuQ= +github.com/aws/aws-sdk-go-v2/config v1.33.9 h1:Hv6bcQUJVnmuWaKjqD6Ot8sE75NrWsWV20oGT5aWle0= +github.com/aws/aws-sdk-go-v2/config v1.33.9/go.mod h1:ivowmrZNfBQLcg4QkaItgi5wae4Bzot9RzIdbY1hfTg= +github.com/aws/aws-sdk-go-v2/credentials v1.20.9 h1:SNO55PnWhaDafsOcv16lCA0kdoFMl/YlegWoVsU02s0= +github.com/aws/aws-sdk-go-v2/credentials v1.20.9/go.mod h1:+2reOTRtdD3VSBqpRDNwrZIYy8yMCYa7oK3WKTJ/I1I= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.3 h1:gr3b87OLB29jymWPzp/CAyXwQRJVE+sC4Wm0qBIKHsk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.3/go.mod h1:BRjf4L/Yx2hY54o+ZibPbLJyMMeSwqYQXwcJfqW66a8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.6 h1:CqL8NN3p4I0JTwwV5MEoatIl4Y2amZ2X9dRD+0hbRL0= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.6/go.mod h1:exdJ32hMqXl0Wyo6NwqEC5yIhtua3+PDaKE8P29m4qE= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.6 h1:FpBq24TG6WVSObTNhQOv7Q0088rounUUlwn3zlNItkM= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.6/go.mod h1:ONzdPVYC95oBOu0VWNEsKEntcoemP3xVKjT+qWml1s4= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6 h1:Qmn60RWuk9+bd2ddvJJqwf3Cpac3VvGMOdbGemtUhX4= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6/go.mod h1:h/YJhe4AVyQxEUMluph10iqV7mjajH7A9L+BHF5ppis= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21 h1:e6YW5uME30TkrElR+4/xcY7NB6CCj2dcv7vtmGXhfhQ= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21/go.mod h1:obpHAMP693+f3/A8PkLhIqfK1C+NWWn8zXzqD3OH520= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.7 h1:CwvFlKw0uUScfgOMflXSRZa+ayXgOf6wk7lhyMemQYU= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.7/go.mod h1:4SbD8BB+p+QEnG0aX6DoWwrtk9SufyYXRsUv0nve8LE= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6 h1:LHHdobjo5le1sjJdolitV7uVvYg3wkaeM1Ju2fDgeZQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6/go.mod h1:CBGB0evAiaMD3Lk4wPO7LQCcCZLuhzL5Qf/wfEUHQ5w= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.6 h1:mYViDr2azcgxOpRrObZOl631bvavqILoE/L+Kxgbwys= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.6/go.mod h1:OXVdAo9jNhggB+hJRGOyy7WolGTKB6Rg7IIulDeSzPM= +github.com/aws/aws-sdk-go-v2/service/s3 v1.114.3 h1:NyeuyXkZL476uoAowNActm4XJO1QPKf7aassgXHpiqA= +github.com/aws/aws-sdk-go-v2/service/s3 v1.114.3/go.mod h1:GfoBeKMDJecgRUsGf/0RGdx0NJAh81ObTd922h8pQys= +github.com/aws/aws-sdk-go-v2/service/signin v1.10.4 h1:tvpDjuUvgKURf3Yk4hOaGbZ4QdY+070OWcA5d6LUv2A= +github.com/aws/aws-sdk-go-v2/service/signin v1.10.4/go.mod h1:3ibVKg5Ng82Zby8++jELTD2nHxiPk2DJRLEeW/LKp8M= +github.com/aws/aws-sdk-go-v2/service/sso v1.38.4 h1:ChCtagixSFOxltv0o6E3NT8F7ksvZtZGPwUBynO5Nmk= +github.com/aws/aws-sdk-go-v2/service/sso v1.38.4/go.mod h1:+FEO9jrVMpc9oN3CdAxJbtlQEu3z8UddDPSedpeM3PY= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.4 h1:j6c+HA4AoS0EBEjArPjavFEBrucJEryfrQU0TffmACk= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.4/go.mod h1:RhghaXUk9q66VtOQUX1FdPddQuHnqQZzbB0fEYXmPRA= +github.com/aws/aws-sdk-go-v2/service/sts v1.51.4 h1:OKEfbf2tcfsF2pBk/S9QqNpo6alaAtiBs4hysCsBjPw= +github.com/aws/aws-sdk-go-v2/service/sts v1.51.4/go.mod h1:mCd6qGo9b8hTtfjgsmNkFlzwjyce4owd7HDVFnCLXhM= +github.com/aws/smithy-go v1.28.6 h1:Pj+/sUviNYjow8ELRtNwKHZ9ECeF+7pATX3+shjLIrw= +github.com/aws/smithy-go v1.28.6/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas= @@ -86,8 +76,8 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5 h1:DTSZxdV9qQagD4iGcAt9RgaRBZtJl01bfKgdLzUzUPI= -github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms= +github.com/charmbracelet/x/exp/slice v0.1.0 h1:m46dwuDf3M5SFYMRhxYkLC6z9WnENGVKbcbJlp13/og= +github.com/charmbracelet/x/exp/slice v0.1.0/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA= github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8= github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ= @@ -98,24 +88,22 @@ github.com/coder/anthropic-sdk-go v0.0.0-20260428122333-47cab198e449 h1:X4XOtomD github.com/coder/anthropic-sdk-go v0.0.0-20260428122333-47cab198e449/go.mod h1:hqlYqR7uPKOKfnNeicUbZp0Ps0GeYFlKYtwh5HGDCx8= github.com/coder/openai-go/v3 v3.0.0-20260810104509-c6da967b6fba h1:XY8zcakes91/AaLABciCM5wkahoF8YL9iAh/NPusmfo= github.com/coder/openai-go/v3 v3.0.0-20260810104509-c6da967b6fba/go.mod h1:Ogjo0gDct+Jm7yCqaCjLGQGygeV8xNfNHV1/yKvCji0= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= -github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE= -github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitengine/purego v0.11.2 h1:WWiTx8rD8zYdOwSj8O/NiNbsOvSAgJMTlyV1XlbUSeU= +github.com/ebitengine/purego v0.11.2/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= -github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= -github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= +github.com/envoyproxy/go-control-plane/envoy v1.40.0 h1:G4dcl8KE/HVPnZ25BBdvGZWi8JjyeBfQSvm95U5P1Qc= +github.com/envoyproxy/go-control-plane/envoy v1.40.0/go.mod h1:UZVGjwWu2QV8PkGSlXmqWiSgqCGZe16TgIu4jDl3T5w= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= -github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= -github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-jose/go-jose/v4 v4.1.6 h1:sNRkftxFJxwsILiQukqp5z83QBSmntVwmgRY7AqBDO4= +github.com/go-jose/go-jose/v4 v4.1.6/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg= github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -127,76 +115,62 @@ github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPE github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= -github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= -github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= -github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/s2a-go v0.1.11 h1:7v1V1TraNGP63WptsAkNqr2w8jgm4vM6A6qx6Lsw9iM= +github.com/google/s2a-go v0.1.11/go.mod h1:pz4tyvwXvJLLbyrkh6FW1eS2zPUXMaTmyNhYtyP2tNw= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.19 h1:mMOE7DN2+p76/EdIrmAy9B9bH+yC4563vmnJ34QR8i4= -github.com/googleapis/enterprise-certificate-proxy v0.3.19/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k= -github.com/googleapis/gax-go/v2 v2.23.0 h1:Tchl7qkvE7Ip3y+ztvNufYFvkfqTe7NfLTYGIdJRLuE= -github.com/googleapis/gax-go/v2 v2.23.0/go.mod h1:rBQKOVJCdb8IFEzg+FCwlt1LP/xMDGuqUXhUG+XMXEg= +github.com/googleapis/enterprise-certificate-proxy v0.3.23 h1:J+VGF1wjAM2dU2kKUXZxJdDppRLReWP7KWcclfngQhc= +github.com/googleapis/enterprise-certificate-proxy v0.3.23/go.mod h1:Y8NO/15GNLkrXI3OR5xOFmeU3XLX1sLSdtlXgJ0zmIw= +github.com/googleapis/gax-go/v2 v2.27.0 h1:qe2iQOUzxb3fd6x6ewpIEixPQhJ+0Ihc/RRZhKA/2g0= +github.com/googleapis/gax-go/v2 v2.27.0/go.mod h1:RofiqDw3ehcG4L0H1zO3D2Itpwd3/uS/7wdmbNDF1DE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= -github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.74 h1:mymLUKThnV9wFvogOK8NnsMP9/vlhnjXY98gr2QIGW8= -github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.74/go.mod h1:Bh9qYL8ehmDxSg14Tk8oxFCP90XHfs6NxV1D84884xA= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.32.0 h1:QMILzF8BA7xftE4zVORkRrlv08m12St3csK4RsyRgRs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.32.0/go.mod h1:+ZQ6iUL8+ATyxGDQNuRKpNwJFdGDUi0e7E1QL9MvI7U= +github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.76 h1:lv1kai0y585J8lMNBhyOoVQssXp3pBv5lLeKb38n+BY= +github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.76/go.mod h1:LlNlsklP+YHM+y1x1SNdOgbXIih0fyqpvB8iGik/sDo= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= -github.com/hashicorp/go-getter v1.8.6 h1:9sQboWULaydVphxc4S64oAI4YqpuCk7nPmvbk131ebY= -github.com/hashicorp/go-getter v1.8.6/go.mod h1:nVH12eOV2P58dIiL3rsU6Fh3wLeJEKBOJzhMmzlSWoo= +github.com/hashicorp/go-getter v1.8.10 h1:uufJp+uacA0fSDRxclsHkiE6yUQTKgwv5b25QexgVM8= +github.com/hashicorp/go-getter v1.8.10/go.mod h1:5HyKGCQmT+I3MGAM76vk/fWpktjwxn7xruCUr9CrVuo= github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hybridgroup/yzma v1.21.0 h1:xBgCH9ziielLeexci8eJ/PvnxCzgLktOaoHH5p/ef30= -github.com/hybridgroup/yzma v1.21.0/go.mod h1:wAMtNTjSnE79jTWzaeFSsuraam0TXKCPajMHA5s+OX8= +github.com/hybridgroup/yzma v1.23.0 h1:AqwwhnPOS0I4TzRTCuWxXqGK/kY2P+QZ3+qiJOM3Jl0= +github.com/hybridgroup/yzma v1.23.0/go.mod h1:wAMtNTjSnE79jTWzaeFSsuraam0TXKCPajMHA5s+OX8= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/jupiterrider/ffi v0.7.0 h1:RKsl6Ascal+3kyAqR5Qcbp83LceQMLc1VZbPfHWoNzs= -github.com/jupiterrider/ffi v0.7.0/go.mod h1:9dauhpOfNqrqk28fxuu0kkdeFtT9Qr4vbfigiuIXN7c= +github.com/jupiterrider/ffi v0.8.0 h1:IH6lto8OZaRIulQYObtZibII4tq71hXT6Z+KLBcxCcQ= +github.com/jupiterrider/ffi v0.8.0/go.mod h1:9dauhpOfNqrqk28fxuu0kkdeFtT9Qr4vbfigiuIXN7c= github.com/kaptinlin/jsonpointer v0.4.28 h1:PyxOfdml9PGpxQz+JnNLGsyzQ+Rnm+OVNTGcgbVLqgg= github.com/kaptinlin/jsonpointer v0.4.28/go.mod h1:JzeIDw1+60bryzOFea9P3zjKg8pfJHd+UIA/zfZ70x8= github.com/kaptinlin/jsonschema v0.9.8 h1:srpfx2MAm4ecYz0zJLbz6fiW+UXiuv+vGhUqZUIWmrg= github.com/kaptinlin/jsonschema v0.9.8/go.mod h1:TP7RFmFBNExjBE0b3fsM6RpA/9PeOzfg2g4weXqOrDg= -github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= -github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/klauspost/compress v1.20.3 h1:zWNoFqq/lSuYQRim3dUXlXeA0IqwuZGicdG5ZTfY5j4= +github.com/klauspost/compress v1.20.3/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= -github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= -github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= -github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= -github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= -github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= -github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= -github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/spiffe/go-spiffe/v2 v2.8.1 h1:eXZMLsu+3MLEPJyGJkolqtVrteZfQdUpOWj6LTiDl/E= -github.com/spiffe/go-spiffe/v2 v2.8.1/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/prometheus/client_golang v1.25.0 h1:cDtW1TcFsMi1RJc8khwCKB4VhzwV04aKqbYtNOXuiK0= +github.com/prometheus/client_golang v1.25.0/go.mod h1:j2X/4/kaQHLPnmyEU8zjmkndo1T317VpCIQEml8LTKk= +github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo= +github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM= +github.com/prometheus/common v0.72.1 h1:ZAiQed/nk8IIcsdzb2TlJu23NEUDyDLQ9TXTN2WRK6Y= +github.com/prometheus/common v0.72.1/go.mod h1:1IoBY4IRaJnhs2ESjNzjilP1cJ/P8pimGoomq7zRXrA= +github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics= +github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58= +github.com/spiffe/go-spiffe/v2 v2.9.0 h1:g5qTg3qIP1HXV+GzuyEfSJFVxUCpEP5Ui1OINVJHPB4= +github.com/spiffe/go-spiffe/v2 v2.9.0/go.mod h1:w2CLWKLMTX/PPYUEUPv3ltH0RXsw5S8suwNF46w9/Aw= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= @@ -207,80 +181,79 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -github.com/ulikunitz/xz v0.5.16 h1:ld6NyySjx5lowVKwJvMRLnW5nxKX/xnpSiFYZ/Lxur0= -github.com/ulikunitz/xz v0.5.16/go.mod h1:H9Rt/W6/Qj27PGauhQc6nfCDy7vHpzsOThBSaYDoEhw= +github.com/ulikunitz/xz v0.5.17 h1:flR0y/x1hgM8EGV1AW3Xll6T413G0glV8UfBwR617V4= +github.com/ulikunitz/xz v0.5.17/go.mod h1:H9Rt/W6/Qj27PGauhQc6nfCDy7vHpzsOThBSaYDoEhw= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/detectors/gcp v1.44.0 h1:NmLfL734pJhM0JKaYd2Y28+nY9dPRWYAAbxhRCrKXPw= -go.opentelemetry.io/contrib/detectors/gcp v1.44.0/go.mod h1:tNAsgd8avTGke1+MndXlU5Cru4PQ9Ai/cCNWQv/ZJ/s= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 h1:2yEATaop1/a1I4psnSLgWVPLWwCzkqWakgJy7xTDVy0= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= -go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= -go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 h1:hqxVTu/GtBF+vJ8d1fzW7fRxZFvgoDjWcxwwCaFDYpU= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q= -go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= -go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= -go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= -go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= -go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= -go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= -go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= -go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= -go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= -go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= -go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= +go.opentelemetry.io/contrib/detectors/gcp v1.47.0 h1:Ct6+aaiSO6Nk5SOb6PYcR7iWp+83YF/pL1OJcGqOmbg= +go.opentelemetry.io/contrib/detectors/gcp v1.47.0/go.mod h1:3N259ss0b1AVOs+fYw7lOO0mcUwNqSIpQ+Zitpuqx00= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.72.0 h1:Tq+E65HaNbZTeqcLw6QUhsV+/EIWdkfUH2CQzcrlE5M= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.72.0/go.mod h1:A+EVhDakAj1waJBPhmTszZaSr08ww8MRRKy1nB1rqck= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0 h1:LxwW/9ctSCv+QkE/cLR7M91ZIkXNMqJtEMi1vCw9U8s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0/go.mod h1:tOsftB4SslBwwErVEPaenU2RpThXWPIU8DoJHEC4dyw= +go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM= +go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 h1:julhjPeUH/q/7hinbSdDdqt5h7Zw9YWmRlWRhI0jd54= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0/go.mod h1:Ao2mz688LH/tFf0yMAenidq6k2YNSx6SIY2q6jDACck= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0 h1:UFpxOpYPmMNUtOWhdb+nC1WELIgVf8z9higURrbzYU4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0/go.mod h1:YBGjxe3lt0jtXoEXxGrGhv/jM7oUBUDVK7zhAvNyEjc= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.45.0 h1:dm9iyzn6tioYZtwqaiBSU0TSI8Yu/8dTIbfG0+B49DY= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.45.0/go.mod h1:xAvxYjYK28qvt+yu4BYZ/zMmAjwMXINXD6JiMyeB8iI= +go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI= +go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw= +go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY= +go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU= +go.opentelemetry.io/otel/metric/x v0.69.0 h1:DjRLr15H83v+hCW7JA9NoJvOkYTtmq5YoDRbe9deYpM= +go.opentelemetry.io/otel/metric/x v0.69.0/go.mod h1:uVvsMPMFFyj/HUQfrUnH3JjnOQ1dwFDorgFLRBasM0k= +go.opentelemetry.io/otel/sdk v1.47.0 h1:zWXEr4j2lFefG87TU6Yg8a7ngfohIKFZHKp0Hf5hC6I= +go.opentelemetry.io/otel/sdk v1.47.0/go.mod h1:VUc24kiOeoGsxG8G9ULx3fWKvB7jMhnGE8Oi607lgR0= +go.opentelemetry.io/otel/sdk/metric v1.47.0 h1:lfISg2j93VT6yqdk9OfUaZmw/GfcZqCCV3jdXtsPnKw= +go.opentelemetry.io/otel/sdk/metric v1.47.0/go.mod h1:ypLp+mW1Nt2x+Szt3b5/i1syodyts49lMOwxpDI3VGw= +go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM= +go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0= +go.opentelemetry.io/proto/otlp v1.11.1 h1:SCrPqH9NE5CmBrOqpjNCiT0X5mGwMyGERXdogfR7Yjw= +go.opentelemetry.io/proto/otlp v1.11.1/go.mod h1:wq8W4aSf6bOy8RZyESOeQFr9Pu7wydWX/TY4sgvrTPg= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go= go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= -golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0= -golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4= -golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= -golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= -golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= -golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= -golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= -golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= +golang.org/x/image v0.47.0 h1:za/8gVz++PPZqmZJBNSISICN5Rrj5zixP/8K++fgmqo= +golang.org/x/image v0.47.0/go.mod h1:IkN5o4A7vq55vXN8FWjjnhg9Rs2GOtffqsSi9mWAKYE= +golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= +golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= +golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= +golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.291.0 h1:wfPbbY+mr9c7wZLqqzrHJLft/q8iFKREd6IgTBUene0= -google.golang.org/api v0.291.0/go.mod h1:at7kwWbuonglBFEBoeMDAV1bguHqL3qf0BHFsv3coa0= -google.golang.org/genai v1.67.0 h1:m3V2AK2zbyViUq9rhTkMgkxu32Fueypy5Pzd9yV8GwU= -google.golang.org/genai v1.67.0/go.mod h1:mDdPDFXo1Ats7f1WXVyZgWb/CkMzFWTWJruIMy7hGIU= -google.golang.org/genproto v0.0.0-20260729162451-8efbd57d26e0 h1:xJf8e9ReUqiexuIT3OWJkvA4RdgR8K4Hfheq8gaKxIM= -google.golang.org/genproto v0.0.0-20260729162451-8efbd57d26e0/go.mod h1:0MNk3ibJAyOwDZVlp18knQe3jRyFxpcU06xjxhgjx0M= -google.golang.org/genproto/googleapis/api v0.0.0-20260729162451-8efbd57d26e0 h1:ybvH/ZpOcpCrjtkb7oW/fdlzbEmRVeumw19SRQmNFKU= -google.golang.org/genproto/googleapis/api v0.0.0-20260729162451-8efbd57d26e0/go.mod h1:HJ9MpJLeDSstBkx1LILTpd5f41ADSMZcTPypw02qEGw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0 h1:mJiOtnGp0k/BcSgdu03G2NwnscCfCH+h2QKUBZr18KI= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ= -google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +google.golang.org/api v0.307.0 h1:KJJv1V/cOeCUrgKFippAhSg98QlR2IZFqubQKf7gQdg= +google.golang.org/api v0.307.0/go.mod h1:4XMH+b/ZEfuTbK7FnrFRY9c4xfFHVflGTKuy/i5wUmE= +google.golang.org/genai v1.71.0 h1:Wfo9n0uSzMhZH7d+rP7QxxSWELEDSD4z6O8W/C9s3oM= +google.golang.org/genai v1.71.0/go.mod h1:mDdPDFXo1Ats7f1WXVyZgWb/CkMzFWTWJruIMy7hGIU= +google.golang.org/genproto v0.0.0-20261019180522-ce908d29299b h1:+t2qGqt6R8CO622uiX1JpnRvRvSOQoVRV0DiPzUjKXg= +google.golang.org/genproto v0.0.0-20261019180522-ce908d29299b/go.mod h1:XRIfc1yr5xsbF8xFw5+g7SEmLJX/eQ7MMazTRbnMMvI= +google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b h1:nB3spRIaSPtxJpxb0k41yljROvm2Jrwfs2pSNdKS5y8= +google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b/go.mod h1:9N4vtjEwvgQKEdlFVQO0M44ErhecQ9DCSQfGoZ51M6w= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b h1:6WNWHZk91dp4gZ25642OALkuG1UVNZbwENJD19DnB8k= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= +google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= +google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290 h1:g3ah7zaWmw41EtOgBNXpx8zk4HYuH3OMwB+qh1Dt834= gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290/go.mod h1:sbq5oMEcM4PXngbcNbHhzfCP9OdZodLhrbRYoyg09HY= gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/json_test.go b/json_test.go index 23958d97b..f461300b8 100644 --- a/json_test.go +++ b/json_test.go @@ -315,7 +315,8 @@ func TestHelperFunctions(t *testing.T) { }) t.Run("NewUserMessage - with files", func(t *testing.T) { - msg := NewUserMessage("Check this image", + msg := NewUserMessage( + "Check this image", FilePart{ Filename: "image1.jpg", Data: []byte{0xFF, 0xD8, 0xFF}, diff --git a/jsonrepair/escapes_regression_test.go b/jsonrepair/escapes_regression_test.go new file mode 100644 index 000000000..307c0dacb --- /dev/null +++ b/jsonrepair/escapes_regression_test.go @@ -0,0 +1,34 @@ +package jsonrepair + +import ( + "encoding/json" + "reflect" + "testing" +) + +func TestRepairJSONStandardEscapes(t *testing.T) { + cases := []string{ + `{"text":"page\fbreak"}`, + `{"text":"https:\/\/example.com\/path"}`, + `{"\f":"value"}`, + `["\f", "\/", "\b", "\t", "\n", "\r"]`, + } + for _, input := range cases { + t.Run(input, func(t *testing.T) { + var want, got any + if err := json.Unmarshal([]byte(input), &want); err != nil { + t.Fatal(err) + } + repaired, err := RepairJSON(input) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(repaired), &got); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("RepairJSON(%q) = %q; decoded got %#v, want %#v", input, repaired, got, want) + } + }) + } +} diff --git a/jsonrepair/jsonrepair.go b/jsonrepair/jsonrepair.go index 26aa5b98a..5b596002d 100644 --- a/jsonrepair/jsonrepair.go +++ b/jsonrepair/jsonrepair.go @@ -231,6 +231,13 @@ func (p *parser) parseJSON() (any, error) { return p.parseNumber() } } + // Only the JSON quote character, not every isStringDelimiter rune: text + // that begins with an apostrophe or a curly quote has to stay junk. + if p.context.empty && char == '"' { + if onlyWhitespaceBefore(p) { + return p.parseString() + } + } if char == '#' || char == '/' { return p.parseComment() } @@ -436,7 +443,7 @@ func (p *parser) parseNumber() (any, error) { numberStr := "" char, ok := p.getCharAt(0) isArray := p.context.current != nil && *p.context.current == contextArray - for ok && strings.ContainsRune(numberChars, char) && (!isArray || char != ',' || strings.Contains(numberStr, "/")) { + for ok && (strings.ContainsRune(numberChars, char) || isExponentSign(char, numberStr)) && (!isArray || char != ',' || strings.Contains(numberStr, "/")) { if char != '_' { numberStr += string(char) } @@ -447,17 +454,18 @@ func (p *parser) parseNumber() (any, error) { p.index -= len([]rune(numberStr)) return p.parseString() } - if len(numberStr) > 0 { + for len(numberStr) > 0 { last := numberStr[len(numberStr)-1] - if last == '-' || last == 'e' || last == 'E' || last == '/' || last == ',' { - numberStr = numberStr[:len(numberStr)-1] - p.index-- + if last != '-' && last != 'e' && last != 'E' && last != '/' && last != ',' && last != '+' { + break } + numberStr = numberStr[:len(numberStr)-1] + p.index-- } - if strings.Contains(numberStr, "/") || strings.Contains(numberStr, "-") || strings.Contains(numberStr, ",") { - if numberStr == "-" { - return "", nil - } + if numberStr == "" || numberStr == "-" { + return "", nil + } + if hasNumericSeparator(numberStr) { if strings.ContainsAny(numberStr, "eE") { floatVal, err := strconv.ParseFloat(numberStr, 64) if err == nil { @@ -468,20 +476,90 @@ func (p *parser) parseNumber() (any, error) { } return numberStr, nil } + if hasInvalidJSONIntegerPart(numberStr) { + // RFC 8259: int = zero / ( digit1-9 *DIGIT ). A literal with a leading zero + // is not a JSON number, so keeping it as one would hand back a document that + // still does not parse. Keep the literal as a string instead. + return numberStr, nil + } if strings.ContainsAny(numberStr, ".eE") { floatVal, err := strconv.ParseFloat(numberStr, 64) - if err == nil { - formatted := formatFloat(floatVal) - return numberValue{raw: formatted}, nil + if err != nil { + // RFC 8259 puts no bound on the exponent, so a literal larger than float64 is + // still a valid JSON number. Keep it a number: returning the text here hands + // the caller a string where the document said number. + if errors.Is(err, strconv.ErrRange) { + return numberValue{raw: numberStr}, nil + } + return numberStr, nil } - return numberStr, nil - } - if numberStr == "" { - return "", nil + if floatVal == 0 && !isZeroLiteral(numberStr) { + // Underflow reports zero with no error, so formatting it would replace the + // value with 0.0. The literal is valid JSON, so keep the caller's text. + return numberValue{raw: numberStr}, nil + } + formatted := formatFloat(floatVal) + return numberValue{raw: formatted}, nil } return numberValue{raw: numberStr}, nil } +// isZeroLiteral reports whether the mantissa of a number literal has no nonzero digit, +// which is what separates a literal that really is zero from one that float64 rounded +// down to it. The exponent does not decide this: 0e+2 is a zero. +func isZeroLiteral(numberStr string) bool { + mantissa := numberStr + if idx := strings.IndexAny(mantissa, "eE"); idx >= 0 { + mantissa = mantissa[:idx] + } + for _, r := range mantissa { + if r >= '1' && r <= '9' { + return false + } + } + return true +} + +// hasInvalidJSONIntegerPart reports whether the literal's integer part starts with a +// zero that is followed by more digits, optionally behind a sign: "01", "007", +// "01.5", "-01e2". A bare "0" is a valid JSON number, and so are "0.5" and "0e2", +// where the digits after the fraction or the exponent say nothing about the int part. +func hasInvalidJSONIntegerPart(numberStr string) bool { + digits := strings.TrimPrefix(numberStr, "-") + if i := strings.IndexAny(digits, ".eE"); i >= 0 { + digits = digits[:i] + } + return len(digits) > 1 && digits[0] == '0' +} + +// isExponentSign reports whether char is the "+" of an exponent such as "1e+2". +// "+" is deliberately left out of numberChars: anywhere else it is not part of +// a JSON number and has to end the literal. +func isExponentSign(char rune, numberStr string) bool { + if char != '+' || numberStr == "" { + return false + } + last := numberStr[len(numberStr)-1] + return last == 'e' || last == 'E' +} + +// hasNumericSeparator reports whether the characters collected as a number are +// interleaved with something that cannot appear in a JSON number, such as the +// "-" of "10-20" or the "/" of "1/3". A "-" at the front is a sign and a "-" +// right after an exponent marker belongs to the exponent, so neither counts: +// both leave a literal that must keep its number type. +func hasNumericSeparator(numberStr string) bool { + if strings.ContainsAny(numberStr, "/,") { + return true + } + for i := 1; i < len(numberStr); i++ { + if numberStr[i] == '-' && numberStr[i-1] != 'e' && numberStr[i-1] != 'E' { + return true + } + } + return false +} + func (p *parser) parseObject() (any, error) { obj := newOrderedObject() startIndex := p.index @@ -914,9 +992,9 @@ func (p *parser) parseString() (any, error) { } if len(stringAcc) > 0 && stringAcc[len(stringAcc)-1] == '\\' { p.log("Found a stray escape sequence, normalizing it") - if char == rdelim || char == 't' || char == 'n' || char == 'r' || char == 'b' || char == '\\' { + if char == rdelim || char == 't' || char == 'n' || char == 'r' || char == 'b' || char == 'f' || char == '/' || char == '\\' { stringAcc = stringAcc[:len(stringAcc)-1] - escapeSeqs := map[rune]rune{'t': '\t', 'n': '\n', 'r': '\r', 'b': '\b'} + escapeSeqs := map[rune]rune{'t': '\t', 'n': '\n', 'r': '\r', 'b': '\b', 'f': '\f'} if replacement, ok := escapeSeqs[char]; ok { stringAcc = append(stringAcc, replacement) } else { @@ -940,8 +1018,14 @@ func (p *parser) parseString() (any, error) { if len(nextChars) == numChars && isHexString(string(nextChars)) { p.log("Found a unicode escape sequence, normalizing it") parsed, _ := strconv.ParseInt(string(nextChars), 16, 32) - stringAcc = append(stringAcc[:len(stringAcc)-1], rune(parsed)) + code := rune(parsed) p.index += 1 + numChars + if char == 'u' { + if paired, isPair := p.readSurrogatePair(code); isPair { + code = paired + } + } + stringAcc = append(stringAcc[:len(stringAcc)-1], code) char, ok = p.getCharAt(0) continue } @@ -1143,7 +1227,12 @@ func (p *parser) parseString() (any, error) { } else { p.index++ } - if !p.streamStable && (missingQuotes || (len(stringAcc) > 0 && stringAcc[len(stringAcc)-1] == '\n')) { + // A raw line break inside the string is string content, like a tab or a + // carriage return, and is escaped on output. It is only dropped when the + // closing quote is missing, which the branch above already handles: reaching + // this point with a closing quote means the string was terminated, and trimming + // there deleted a character the caller wrote. + if !p.streamStable && missingQuotes { stringAcc = trimRightWhitespace(stringAcc) } if missingQuotes && p.context.empty { @@ -1175,6 +1264,31 @@ func (p *parser) parseString() (any, error) { return string(stringAcc), nil } +// readSurrogatePair consumes the \uXXXX escape that follows a surrogate, so a +// non-BMP character decodes to one code point instead of two invalid ones. +func (p *parser) readSurrogatePair(high rune) (rune, bool) { + if !utf16.IsSurrogate(high) { + return 0, false + } + if p.sliceString(p.index, p.index+2) != "\\u" { + return 0, false + } + digits := p.sliceString(p.index+2, p.index+6) + if !isHexString(digits) { + return 0, false + } + parsed, err := strconv.ParseInt(digits, 16, 32) + if err != nil { + return 0, false + } + combined := utf16.DecodeRune(high, rune(parsed)) + if combined == unicode.ReplacementChar { + return 0, false + } + p.index += 6 + return combined, true +} + func (p *parser) parseBooleanOrNull() any { char, ok := p.getCharAt(0) if !ok { @@ -1460,11 +1574,10 @@ func RepairJSON(input string, opts ...Option) (string, error) { return "", err } if str, ok := value.(string); ok { - trimmed := strings.TrimSpace(str) - if str == "" || trimmed == "" { + if strings.TrimSpace(str) == "" { return "", nil } - return "", nil + return serialize(value, ensureASCIIValue(cfg)), nil } if value == "" { return "", nil diff --git a/jsonrepair/jsonrepair_test.go b/jsonrepair/jsonrepair_test.go index 5340543ff..77127b703 100644 --- a/jsonrepair/jsonrepair_test.go +++ b/jsonrepair/jsonrepair_test.go @@ -742,6 +742,28 @@ func TestParseNumber(t *testing.T) { "value": json.Number("1234.56"), }, }, + { + name: "positive_exponent", + input: "2E+3", + want: json.Number("2000.0"), + }, + { + name: "negative_integer", + input: "-10", + want: json.Number("-10"), + }, + { + name: "negative_float", + input: "-0.5", + want: json.Number("-0.5"), + }, + { + name: "negative_in_object", + input: "{\"offset\": -10}", + want: map[string]any{ + "offset": json.Number("-10"), + }, + }, } for _, tc := range cases { @@ -750,8 +772,10 @@ func TestParseNumber(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } + // The types matter here: "%#v" prints a string and a json.Number + // identically, so a value-level message hides the real regression. if !reflect.DeepEqual(got, tc.want) { - t.Fatalf("got %#v want %#v", got, tc.want) + t.Fatalf("got %#v (%T) want %#v (%T)", got, got, tc.want, tc.want) } }) } @@ -803,6 +827,43 @@ func TestParseNumberEdgeCases(t *testing.T) { input: "{\"key\": 1/3, \"foo\": \"bar\"}", want: "{\"key\": \"1/3\", \"foo\": \"bar\"}", }, + // Regression tests for #375: a leading "-" is a sign, so a negative + // number is a valid JSON number and must not be quoted into a string. + { + name: "negative_integer", + input: "{\"key\": -1}", + want: "{\"key\": -1}", + }, + { + name: "negative_float", + input: "{\"key\": -3.5}", + want: "{\"key\": -3.5}", + }, + { + name: "negative_zero", + input: "{\"key\": -0}", + want: "{\"key\": -0}", + }, + { + name: "negative_in_array", + input: "{\"offsets\": [-1, -2]}", + want: "{\"offsets\": [-1, -2]}", + }, + { + name: "negative_exponent", + input: "{\"key\": -1e-3}", + want: "{\"key\": -0.001}", + }, + { + name: "negative_in_truncated_object", + input: "{\"offset\": -1", + want: "{\"offset\": -1}", + }, + { + name: "negative_in_array_of_objects", + input: "[{\"a\": -1}, {\"b\": -2.5}]", + want: "[{\"a\": -1}, {\"b\": -2.5}]", + }, { name: "dash_number", input: "{\"key\": 10-20}", @@ -828,6 +889,78 @@ func TestParseNumberEdgeCases(t *testing.T) { input: "{\"key\": 1e10 }", want: "{\"key\": 10000000000.0}", }, + // A "+" belongs to the exponent, so the literal stays one number. + { + name: "positive_exponent", + input: "{\"key\": 1E+2}", + want: "{\"key\": 100.0}", + }, + { + name: "lowercase_positive_exponent", + input: "{\"key\": 1e+2}", + want: "{\"key\": 100.0}", + }, + { + name: "negative_with_positive_exponent", + input: "{\"key\": -1E+2}", + want: "{\"key\": -100.0}", + }, + { + name: "positive_exponent_in_array", + input: "{\"a\": [1E+2, 2e+3]}", + want: "{\"a\": [100.0, 2000.0]}", + }, + { + name: "large_positive_exponent", + input: "{\"key\": 1e+10}", + want: "{\"key\": 10000000000.0}", + }, + // src/index.test.ts:46 and :51 of josdejong/jsonrepair assert these two + // pass through unchanged. formatFloat rewrites the text either way, so + // what is pinned here is the value: on b4bf5f9 "2300e+3" decodes to 2300. + { + name: "upstream_corpus_zero_exponent", + input: "0e+2", + want: "0.0", + }, + { + name: "upstream_corpus_large_exponent", + input: "2300e+3", + want: "2300000.0", + }, + // A number bigger than float64 is still a valid JSON number, so it stays one. + { + name: "exponent_overflows_float64", + input: "{\"key\": 1e999}", + want: "{\"key\": 1e999}", + }, + { + name: "positive_exponent_overflows_float64", + input: "{\"key\": 1.0e+400}", + want: "{\"key\": 1.0e+400}", + }, + // float64 underflows to zero without reporting an error, which would + // rewrite the value. + { + name: "exponent_underflows_float64", + input: "{\"key\": 1e-999}", + want: "{\"key\": 1e-999}", + }, + { + name: "negative_exponent_underflows_float64", + input: "{\"key\": -1e-400}", + want: "{\"key\": -1e-400}", + }, + { + name: "truncated_positive_exponent", + input: "{\"key\": 1E+}", + want: "{\"key\": 1}", + }, + { + name: "plus_outside_exponent", + input: "{\"key\": 1+2}", + want: "{\"key\": 1}", + }, { name: "bad_exponent", input: "{\"key\": 1e }", @@ -1225,6 +1358,187 @@ func TestParseStringBasics(t *testing.T) { } } +// A document may be a single JSON string, so repairing one has to hand it back +// rather than returning nothing. +func TestTopLevelString(t *testing.T) { + cases := []struct { + name string + input string + want string + }{ + { + name: "quoted_word", + input: `"str"`, + want: `"str"`, + }, + { + name: "quoted_number", + input: `"42"`, + want: `"42"`, + }, + { + name: "quoted_boolean_word", + input: `"true"`, + want: `"true"`, + }, + { + name: "quoted_phrase", + input: `"a b"`, + want: `"a b"`, + }, + { + name: "quoted_non_ascii", + input: `"★"`, + want: `"\u2605"`, + }, + // Guards: the blank rule and the junk around a quote stay as they are. + { + name: "blank_quoted", + input: `" "`, + want: "", + }, + { + name: "empty_quoted", + input: `""`, + want: "", + }, + { + name: "lone_quote", + input: `"`, + want: "", + }, + { + name: "bare_word", + input: `string`, + want: "", + }, + { + name: "word_before_object", + input: `stringbeforeobject {}`, + want: `{}`, + }, + { + name: "single_quoted", + input: "'key'", + want: "", + }, + { + name: "quote_after_text", + input: `he said "hi" now`, + want: "", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := RepairJSON(tc.input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Fatalf("got %q want %q", got, tc.want) + } + }) + } +} + +// The option paths have to agree with the default one. +func TestTopLevelStringOptions(t *testing.T) { + cases := []struct { + name string + input string + opts []Option + want string + }{ + { + name: "strict", + input: `"str"`, + opts: []Option{WithStrict()}, + want: `"str"`, + }, + { + name: "stream_stable", + input: `"str"`, + opts: []Option{WithStreamStable()}, + want: `"str"`, + }, + { + name: "skip_json_loads", + input: `"str"`, + opts: []Option{WithSkipJSONLoads()}, + want: `"str"`, + }, + { + name: "ensure_ascii_off", + input: `"★"`, + opts: []Option{WithEnsureASCII(false)}, + want: `"★"`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := RepairJSON(tc.input, tc.opts...) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Fatalf("got %q want %q", got, tc.want) + } + }) + } +} + +// Repairing the repaired string must not change it again. +func TestTopLevelStringIdempotent(t *testing.T) { + once, err := RepairJSON(`"str"`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + twice, err := RepairJSON(once) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if once != `"str"` || twice != once { + t.Fatalf("first pass %q, second pass %q", once, twice) + } +} + +func TestLoadsTopLevelString(t *testing.T) { + cases := []struct { + name string + input string + want string + }{ + { + name: "quoted_word", + input: `"str"`, + want: "str", + }, + { + name: "quoted_number", + input: `"42"`, + want: "42", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := Loads(tc.input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + str, ok := got.(string) + if !ok { + t.Fatalf("got %#v (%T) want the string %q", got, got, tc.want) + } + if str != tc.want { + t.Fatalf("got %q want %q", str, tc.want) + } + }) + } +} + func TestMissingAndMixedQuotes(t *testing.T) { cases := []struct { name string @@ -1370,7 +1684,71 @@ func TestEscaping(t *testing.T) { { name: "newline_in_key", input: "{\"key_1\n\": \"value\"}", - want: "{\"key_1\": \"value\"}", + want: "{\"key_1\\n\": \"value\"}", + }, + // Regression tests for #373: a newline that came from an escape sequence is + // string content, so repairing an already valid document must not trim it. + { + name: "escaped_newline_at_end_of_value", + input: "{\"key\": \"line\\n\"}", + want: "{\"key\": \"line\\n\"}", + }, + { + name: "escaped_newline_at_end_of_key", + input: "{\"key\\n\": \"value\"}", + want: "{\"key\\n\": \"value\"}", + }, + { + name: "escaped_crlf_at_end_of_value", + input: "{\"key\": \"line\\r\\n\"}", + want: "{\"key\": \"line\\r\\n\"}", + }, + { + name: "escaped_newlines_at_end_of_value", + input: "{\"key\": \"line\\n\\n\"}", + want: "{\"key\": \"line\\n\\n\"}", + }, + { + name: "unicode_escaped_newline_at_end_of_value", + input: "{\"key\": \"line\\u000a\"}", + want: "{\"key\": \"line\\n\"}", + }, + { + name: "trailing_space_before_escaped_newline", + input: "{\"key\": \"line \\n\"}", + want: "{\"key\": \"line \\n\"}", + }, + { + name: "escaped_newline_before_trailing_space", + input: "{\"key\": \"line\\n \"}", + want: "{\"key\": \"line\\n \"}", + }, + { + name: "raw_newline_at_end_of_value", + input: "{\"key\": \"line\n\"}", + want: "{\"key\": \"line\\n\"}", + }, + // A raw line break is string content exactly like a raw tab or carriage + // return, which the cases above keep, so it must survive the repair too. + { + name: "raw_newline_at_end_of_key", + input: "{\"key\n\": \"value\"}", + want: "{\"key\\n\": \"value\"}", + }, + { + name: "raw_newlines_at_end_of_value", + input: "{\"key\": \"line\n\n\"}", + want: "{\"key\": \"line\\n\\n\"}", + }, + { + name: "raw_newline_at_end_of_nested_value", + input: "{\"key\": [\"line\n\"]}", + want: "{\"key\": [\"line\\n\"]}", + }, + { + name: "raw_newline_before_trailing_space", + input: "{\"key\": \"line\n \"}", + want: "{\"key\": \"line\\n \"}", }, { name: "tab_in_key", @@ -1382,6 +1760,39 @@ func TestEscaping(t *testing.T) { input: "{\"key\": '\u0076\u0061\u006c\u0075\u0065'}", want: "{\"key\": \"value\"}", }, + // A literal with a leading zero is not a JSON number, so it is kept as a + // string: returning it as a number would produce a document that still + // does not parse. + { + name: "number_with_leading_zero", + input: "{\"key\": 01}", + want: "{\"key\": \"01\"}", + }, + { + name: "number_with_leading_zeros", + input: "{\"key\": 007}", + want: "{\"key\": \"007\"}", + }, + { + name: "negative_number_with_leading_zero", + input: "{\"key\": -01}", + want: "{\"key\": \"-01\"}", + }, + { + name: "float_with_leading_zero", + input: "{\"key\": 01.5}", + want: "{\"key\": \"01.5\"}", + }, + { + name: "exponent_with_leading_zero", + input: "{\"key\": 01e2}", + want: "{\"key\": \"01e2\"}", + }, + { + name: "zero_and_zero_decimal_stay_numbers", + input: "{\"key\": 0, \"key2\": 0.5}", + want: "{\"key\": 0, \"key2\": 0.5}", + }, { name: "unicode_escape_skip_loads", input: "{\"key\": \"\\u0076\\u0061\\u006C\\u0075\\u0065\"}", @@ -1613,3 +2024,137 @@ func TestParseBooleanOrNull(t *testing.T) { }) } } + +// TestRepairJSONUnicodeSurrogatePairs covers the two \uXXXX escapes a non-BMP +// character is encoded as when the JSON text stays ASCII. +func TestRepairJSONUnicodeSurrogatePairs(t *testing.T) { + cases := []struct { + name string + input string + opts []Option + want string + }{ + { + name: "valid_pair_is_preserved", + input: `{"key": "\ud83d\ude00"}`, + want: `{"key": "\ud83d\ude00"}`, + }, + { + name: "pair_in_uppercase_hex", + input: `{"key": "\uD83D\uDE00"}`, + want: `{"key": "\ud83d\ude00"}`, + }, + { + name: "pair_written_literally", + input: `{"key": "\ud83d\ude00"}`, + opts: []Option{WithEnsureASCII(false)}, + want: `{"key": "😀"}`, + }, + { + name: "pair_in_object_key", + input: `{"\ud83d\ude00k": 1}`, + want: `{"\ud83d\ude00k": 1}`, + }, + { + name: "pair_in_array_item", + input: `["\ud83d\ude00", "x"]`, + want: `["\ud83d\ude00", "x"]`, + }, + { + name: "pair_then_bmp_escape", + input: `{"key": "\ud83d\ude00\u263a"}`, + want: `{"key": "\ud83d\ude00\u263a"}`, + }, + { + name: "truncated_object_with_pair", + input: `{"text": "hi \ud83d\ude00"`, + want: `{"text": "hi \ud83d\ude00"}`, + }, + { + name: "lone_high_surrogate_stays_replaced", + input: `{"key": "\ud83d"}`, + want: `{"key": "\ufffd"}`, + }, + { + name: "orphan_low_surrogate_stays_replaced", + input: `{"key": "\ude00"}`, + want: `{"key": "\ufffd"}`, + }, + { + name: "two_high_surrogates_stay_replaced", + input: `{"key": "\ud83d\ud83d"}`, + want: `{"key": "\ufffd\ufffd"}`, + }, + { + name: "high_surrogate_then_bmp_escape", + input: `{"key": "\ud83d\u263a"}`, + want: `{"key": "\ufffd\u263a"}`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := RepairJSON(tc.input, tc.opts...) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Fatalf("got %q want %q", got, tc.want) + } + }) + } +} + +// TestRepairJSONUnicodeMatchesStdlib pins the invariant: repairing valid JSON +// must not change the value it decodes to. +func TestRepairJSONUnicodeMatchesStdlib(t *testing.T) { + nonASCII := []string{ + `"\ud83d\ude00"`, + `"\ud83d\ude00\ud83c\udf89"`, + `"emoji: \ud83d\ude00"`, + `"\ud840\udc00"`, + `"\u263a"`, + `"\ufffd"`, + `"\ud83d"`, + } + for _, raw := range nonASCII { + input := `{"key": ` + raw + `}` + var want any + if err := json.Unmarshal([]byte(input), &want); err != nil { + t.Fatalf("input %q is not valid json: %v", input, err) + } + repaired, err := RepairJSON(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var got any + if err := json.Unmarshal([]byte(repaired), &got); err != nil { + t.Fatalf("repaired %q is not valid json: %v", repaired, err) + } + if !reflect.DeepEqual(want, got) { + t.Fatalf("input %q repaired %q: got %#v want %#v", input, repaired, got, want) + } + } +} + +// TestRepairJSONIdempotentOnOwnOutput feeds the repairer its own result back in, +// as happens when a streamed tool call is repaired again on the next chunk. +func TestRepairJSONIdempotentOnOwnOutput(t *testing.T) { + for _, input := range []string{ + `{"key": "😀 café 中"}`, + `{"key": "\ud83d\ude00"}`, + `{"items": ["\ud83c\udf89", "\ud840\udc00"]}`, + } { + first, err := RepairJSON(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + second, err := RepairJSON(first) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if first != second { + t.Fatalf("input %q: first %q second %q", input, first, second) + } + } +} diff --git a/model.go b/model.go index 9e63c9323..3e3d6b91e 100644 --- a/model.go +++ b/model.go @@ -18,7 +18,8 @@ type Usage struct { } func (u Usage) String() string { - return fmt.Sprintf("Usage{Input: %d, Output: %d, Total: %d, Reasoning: %d, CacheCreation: %d, CacheRead: %d}", + return fmt.Sprintf( + "Usage{Input: %d, Output: %d, Total: %d, Reasoning: %d, CacheCreation: %d, CacheRead: %d}", u.InputTokens, u.OutputTokens, u.TotalTokens, @@ -163,6 +164,10 @@ const ( StreamPartTypeFinish StreamPartType = "finish" // StreamPartTypeError represents error stream part type. StreamPartTypeError StreamPartType = "error" + // StreamPartTypeKeepalive represents a provider event that carries no + // content and only shows the stream is still alive. Consumers reading + // content can ignore it; an idle-timeout watchdog should not. + StreamPartTypeKeepalive StreamPartType = "keepalive" ) // StreamPart represents a part of a streaming response. diff --git a/model_json.go b/model_json.go index 2fd883a2f..994d9997b 100644 --- a/model_json.go +++ b/model_json.go @@ -109,7 +109,7 @@ func (s StreamPart) MarshalJSON() ([]byte, error) { alias Error string `json:"error,omitempty"` }{ - alias: (alias)(s), + alias: alias(s), } // Marshal error to string diff --git a/providers/anthropic/anthropic.go b/providers/anthropic/anthropic.go index 47ca97b9c..e0158174c 100644 --- a/providers/anthropic/anthropic.go +++ b/providers/anthropic/anthropic.go @@ -11,6 +11,7 @@ import ( "io" "maps" "math" + "slices" "strconv" "strings" @@ -54,6 +55,8 @@ func defaultsToAdaptiveThinking(model string) bool { return strings.Contains(model, "claude-mythos-preview") } +// requiresAdaptiveThinking reports whether the model rejects a manual +// budget_tokens configuration and must be sent adaptive thinking instead. func requiresAdaptiveThinking(model string) bool { return defaultsToAdaptiveThinking(model) || defaultsToOmittedOpusThinkingDisplay(model) || thinkingOnByDefault(model) } @@ -233,9 +236,33 @@ func setThinkingDisplay(param interface{ SetExtraFields(map[string]any) }, displ param.SetExtraFields(map[string]any{"display": string(display)}) } +// omittedThinkingDisplayFamilies are the model families that default to +// display "omitted". Matched by substring so dated snapshots and +// platform-qualified ids resolve the same as the bare alias. Keep in step +// with the display list in the thinking docs: +// https://platform.claude.com/docs/en/build-with-claude/thinking#controlling-thinking-display +var omittedThinkingDisplayFamilies = []string{ + "claude-opus-5", + "claude-sonnet-5", + "claude-fable-5", + "claude-mythos-5", +} + +// defaultsToOmittedThinkingDisplay reports whether the model returns empty +// thinking text unless a display is requested. Broader than +// [requiresAdaptiveThinking] on purpose: a display is accepted alongside +// either thinking type, so it is safe to list a model here. func defaultsToOmittedThinkingDisplay(model string) bool { model = strings.ToLower(strings.TrimSpace(model)) - return defaultsToAdaptiveThinking(model) || defaultsToOmittedOpusThinkingDisplay(model) + if defaultsToAdaptiveThinking(model) || defaultsToOmittedOpusThinkingDisplay(model) { + return true + } + for _, family := range omittedThinkingDisplayFamilies { + if strings.Contains(model, family) { + return true + } + } + return false } func defaultsToOmittedOpusThinkingDisplay(model string) bool { @@ -472,6 +499,14 @@ func (a *provider) LanguageModel(ctx context.Context, modelID string) (fantasy.L ) } else { if cfg, err := config.LoadDefaultConfig(ctx); err == nil { + // The upstream Anthropic SDK prioritizes a BearerAuthTokenProvider + // over SigV4 credentials. When using AWS SSO, the default config + // populates both, causing the SSO bearer token to be sent to + // Bedrock, which rejects it ("Invalid API Key format"). Clear + // the provider so the SDK falls back to SigV4 signing. + // AWS_BEARER_TOKEN_BEDROCK is still honored by bedrock.WithConfig + // when the provider is nil. + cfg.BearerAuthTokenProvider = nil cfg.Region = cmp.Or(a.options.bedrockRegion, cfg.Region) clientOptions = append( clientOptions, @@ -1733,6 +1768,11 @@ func (a languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.S var refusalMeta *RefusalMetadata var messageStartUsage *anthropic.Usage + // openToolBlocks holds tool_use blocks announced by content_block_start + // but not yet ended. The accumulator cannot serve this: it drops blocks + // it could not index, and max_tokens truncation sends no stop event. + openToolBlocks := map[int64]*openToolBlock{} + for stream.Next() { chunk := stream.Current() _ = acc.Accumulate(chunk) @@ -1740,8 +1780,18 @@ func (a languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.S case "message_start": usage := chunk.AsMessageStart().Message.Usage messageStartUsage = &usage + if !yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeKeepalive}) { + return + } case "content_block_start": contentBlockType := chunk.ContentBlock.Type + if contentBlockType == "tool_use" || contentBlockType == "server_tool_use" { + openToolBlocks[chunk.Index] = &openToolBlock{ + id: chunk.ContentBlock.ID, + name: chunk.ContentBlock.Name, + providerExecuted: contentBlockType == "server_tool_use", + } + } switch contentBlockType { case "text": if !yield(fantasy.StreamPart{ @@ -1786,6 +1836,15 @@ func (a languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.S } } case "content_block_stop": + // Tool blocks end from openToolBlocks so that an accumulator + // that dropped the block cannot swallow the call. + if open, ok := openToolBlocks[chunk.Index]; ok { + delete(openToolBlocks, chunk.Index) + if !open.close(acc, chunk.Index, yield) { + return + } + continue + } if len(acc.Content)-1 < int(chunk.Index) { continue } @@ -1815,37 +1874,7 @@ func (a languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.S return } case "tool_use": - if !yield(fantasy.StreamPart{ - Type: fantasy.StreamPartTypeToolInputEnd, - ID: contentBlock.ID, - }) { - return - } - if !yield(fantasy.StreamPart{ - Type: fantasy.StreamPartTypeToolCall, - ID: contentBlock.ID, - ToolCallName: contentBlock.Name, - ToolCallInput: string(contentBlock.Input), - }) { - return - } - case "server_tool_use": - if !yield(fantasy.StreamPart{ - Type: fantasy.StreamPartTypeToolInputEnd, - ID: contentBlock.ID, - ProviderExecuted: true, - }) { - return - } - if !yield(fantasy.StreamPart{ - Type: fantasy.StreamPartTypeToolCall, - ID: contentBlock.ID, - ToolCallName: contentBlock.Name, - ToolCallInput: string(contentBlock.Input), - ProviderExecuted: true, - }) { - return - } + // Handled above, from openToolBlocks. case "web_search_tool_result": // Read search results directly from the ContentBlockUnion // struct fields instead of using AsAny(). The Anthropic SDK's @@ -1930,13 +1959,17 @@ func (a languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.S return } case "input_json_delta": - if len(acc.Content)-1 < int(chunk.Index) { + // Resolved from openToolBlocks, not the accumulator: the + // accumulator drops blocks it could not index, and a + // dropped delta silently shortens the tool call. + open, ok := openToolBlocks[chunk.Index] + if !ok { continue } - contentBlock := acc.Content[int(chunk.Index)] + open.input.WriteString(chunk.Delta.PartialJSON) if !yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeToolInputDelta, - ID: contentBlock.ID, + ID: open.id, ToolCallInput: chunk.Delta.PartialJSON, }) { return @@ -1950,8 +1983,18 @@ func (a languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.S if rm := parseAnthropicRefusal(chunk.AsMessageDelta().Delta.RawJSON()); rm != nil { refusalMeta = rm } + if !yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeKeepalive}) { + return + } case "message_stop": sawMessageStop = true + default: + // Catch-all on purpose: Anthropic may add event types and + // documents that unknown ones should be handled gracefully. + // https://platform.claude.com/docs/en/build-with-claude/streaming + if !yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeKeepalive}) { + return + } } } @@ -1979,6 +2022,16 @@ func (a languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.S return } + // A turn stopped at max_tokens sends no content_block_stop for the + // block it was writing. Emit what is still open, truncated arguments + // and all, so the call is reported invalid rather than dropped. + for _, index := range slices.Sorted(maps.Keys(openToolBlocks)) { + open := openToolBlocks[index] + if !open.close(acc, index, yield) { + return + } + } + usage := acc.Usage if messageStartUsage != nil { // message_delta can sum input usage across internal iterations. Use @@ -2004,6 +2057,58 @@ func (a languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.S }, nil } +// openToolBlock is a tool_use block announced on the stream but not yet +// ended. It carries the arguments seen so far so the call can still be +// reported if no content_block_stop arrives. +type openToolBlock struct { + id string + name string + providerExecuted bool + input strings.Builder +} + +// close emits the end-of-input and tool call parts for the block. It reports +// whether the consumer wants more parts. +func (o *openToolBlock) close(acc anthropic.Message, index int64, yield func(fantasy.StreamPart) bool) bool { + if !yield(fantasy.StreamPart{ + Type: fantasy.StreamPartTypeToolInputEnd, + ID: o.id, + ProviderExecuted: o.providerExecuted, + }) { + return false + } + return yield(fantasy.StreamPart{ + Type: fantasy.StreamPartTypeToolCall, + ID: o.id, + ToolCallName: o.name, + ToolCallInput: o.arguments(acc, index), + ProviderExecuted: o.providerExecuted, + }) +} + +// arguments returns the call's arguments as JSON text. Deltas win; a block +// with no deltas falls back to the accumulator, then to an empty object so a +// no-argument call is still valid JSON. +func (o *openToolBlock) arguments(acc anthropic.Message, index int64) string { + if o.input.Len() > 0 { + return o.input.String() + } + // Only trust the accumulator when the block sitting at this index + // carries this call's ID. Index drift between the stream and the + // accumulator is the very thing this tracking exists to survive, so + // reading by position alone would be trusting the one thing already + // known to be unreliable, and another call's arguments are worse than + // none. A negative index cannot address a block at all. + if index >= 0 && int(index) < len(acc.Content) { + if block := acc.Content[index]; block.ID == o.id { + if input := string(block.Input); input != "" { + return input + } + } + } + return "{}" +} + // GenerateObject implements fantasy.LanguageModel. func (a languageModel) GenerateObject(ctx context.Context, call fantasy.ObjectCall) (*fantasy.ObjectResponse, error) { switch a.options.objectMode { diff --git a/providers/anthropic/anthropic_test.go b/providers/anthropic/anthropic_test.go index 457fe0f65..7a6eab17b 100644 --- a/providers/anthropic/anthropic_test.go +++ b/providers/anthropic/anthropic_test.go @@ -1095,7 +1095,8 @@ func TestGenerate_SendsThinkingDisplay(t *testing.T) { options: func() *ProviderOptions { return &ProviderOptions{Thinking: &ThinkingProviderOption{BudgetTokens: 2048}} }, - wantType: "adaptive", + wantType: "adaptive", + wantDisplay: "summarized", }, { name: "older opus models keep provider default", @@ -1184,10 +1185,22 @@ func TestDefaultsToOmittedThinkingDisplay(t *testing.T) { {name: "bedrock opus 4.8", model: "us.anthropic.claude-opus-4-8-20260101-v1:0", want: true}, {name: "mythos preview", model: "claude-mythos-preview", want: true}, {name: "bedrock mythos preview", model: "anthropic.claude-mythos-preview", want: true}, + // The 5 generation also defaults to an omitted display. + {name: "opus 5", model: "claude-opus-5", want: true}, + {name: "opus 5 dated", model: "claude-opus-5-20260115", want: true}, + {name: "bedrock opus 5", model: "us.anthropic.claude-opus-5-v1:0", want: true}, + {name: "sonnet 5", model: "claude-sonnet-5", want: true}, + {name: "fable 5", model: "claude-fable-5", want: true}, + {name: "fable 5.1", model: "claude-fable-5-1", want: true}, + {name: "mythos 5", model: "claude-mythos-5", want: true}, + {name: "mythos 5.1", model: "claude-mythos-5-1", want: true}, + {name: "uppercase and padded", model: " CLAUDE-SONNET-5 ", want: true}, {name: "opus 4.6", model: "claude-opus-4-6-20260101", want: false}, {name: "opus 4 date only", model: "claude-opus-4-20250514", want: false}, {name: "bedrock opus 4 date only", model: "us.anthropic.claude-opus-4-20250514-v1:0", want: false}, {name: "sonnet", model: "claude-sonnet-4-20250514", want: false}, + {name: "sonnet 4.5", model: "claude-sonnet-4-5", want: false}, + {name: "haiku 4.5", model: "claude-haiku-4-5", want: false}, {name: "no minor", model: "claude-opus-4", want: false}, } @@ -1359,13 +1372,13 @@ func TestStream_RequiresMessageStopBeforeFinish(t *testing.T) { require.Contains(t, errorParts[0].Error.Error(), tt.wantErrContain) } - var providerErr *fantasy.ProviderError if tt.wantRetryable { + var providerErr *fantasy.ProviderError require.ErrorAs(t, errorParts[0].Error, &providerErr) require.True(t, providerErr.IsRetryable()) } else { require.NotErrorIs(t, errorParts[0].Error, io.ErrUnexpectedEOF) - if errors.As(errorParts[0].Error, &providerErr) { + if providerErr, ok := errors.AsType[*fantasy.ProviderError](errorParts[0].Error); ok { require.False(t, providerErr.IsRetryable()) require.NotErrorIs(t, providerErr.Cause, io.ErrUnexpectedEOF) } @@ -4033,3 +4046,74 @@ func TestMapFinishReason(t *testing.T) { require.Equal(t, tc.expected, mapFinishReason(tc.reason), "stop_reason %q", tc.reason) } } + +// A long reasoning turn sends pings and nothing else. Those have to reach the +// consumer, or an idle-timeout watchdog cannot tell a thinking model from a +// dead connection. +func TestStream_ForwardsKeepalivesDuringSilence(t *testing.T) { + t.Parallel() + + chunks := []string{ + anthropicSSEEvent("message_start", `{"type":"message_start","message":{"id":"msg_ping","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"stop_reason":null,"usage":{"input_tokens":1,"output_tokens":0}}}`), + anthropicSSEEvent("content_block_start", `{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`), + // The quiet stretch: the model is working, the server is pinging. + anthropicSSEEvent("ping", `{"type":"ping"}`), + anthropicSSEEvent("ping", `{"type":"ping"}`), + anthropicSSEEvent("content_block_delta", `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello"}}`), + anthropicSSEEvent("content_block_stop", `{"type":"content_block_stop","index":0}`), + anthropicSSEEvent("message_delta", `{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":1}}`), + anthropicSSEEvent("message_stop", `{"type":"message_stop"}`), + } + + server, _ := newAnthropicStreamingServer(chunks) + defer server.Close() + + provider, err := New(WithAPIKey("test-api-key"), WithBaseURL(server.URL)) + require.NoError(t, err) + model, err := provider.LanguageModel(context.Background(), "claude-sonnet-4-20250514") + require.NoError(t, err) + + stream, err := model.Stream(context.Background(), fantasy.Call{Prompt: testPrompt()}) + require.NoError(t, err) + + var types []fantasy.StreamPartType + var keepalives int + for part := range stream { + types = append(types, part.Type) + if part.Type == fantasy.StreamPartTypeKeepalive { + keepalives++ + } + } + + // Two pings, plus message_start and message_delta, all of which are + // activity without content. + require.GreaterOrEqual(t, keepalives, 2, "pings must surface as keepalives, got parts: %v", types) + + // The content still arrives, in order, unaffected by the keepalives. + require.Contains(t, types, fantasy.StreamPartTypeTextDelta) + require.Contains(t, types, fantasy.StreamPartTypeFinish) +} + +// An explicit caller preference always wins over the default. +func TestThinkingDisplayRespectsCallerChoice(t *testing.T) { + t.Parallel() + + omittedByChoice := ThinkingDisplayOmitted + display, ok := thinkingDisplay(&ProviderOptions{ThinkingDisplay: &omittedByChoice}, "claude-opus-5") + require.True(t, ok) + require.Equal(t, ThinkingDisplayOmitted, display) +} + +// Widening the display default must not change which models get adaptive +// thinking: that rewrites the request shape, where a display only adds a field. +// Claude 5+ still gets adaptive thinking here, from its version rule +// (thinkingOnByDefault), not from the display default. +func TestDisplayDefaultDoesNotChangeThinkingMode(t *testing.T) { + t.Parallel() + + require.True(t, thinkingOnByDefault("claude-opus-5")) + require.True(t, requiresAdaptiveThinking("claude-opus-5")) + require.True(t, requiresAdaptiveThinking("claude-sonnet-5")) + require.True(t, requiresAdaptiveThinking("claude-opus-4-7")) + require.True(t, requiresAdaptiveThinking("claude-mythos-preview")) +} diff --git a/providers/anthropic/error.go b/providers/anthropic/error.go index 293c3c271..c0ed9846b 100644 --- a/providers/anthropic/error.go +++ b/providers/anthropic/error.go @@ -22,10 +22,9 @@ var anthropicContextPattern = regexp.MustCompile(`prompt is too long:\s*(\d+)\s* const awsCredentialErrorFragment = "failed to refresh cached credentials" //nolint:gosec // false positive: error message fragment, not a credential func toProviderErr(err error) error { - var apiErr *anthropic.Error - if errors.As(err, &apiErr) { + if apiErr, ok := errors.AsType[*anthropic.Error](err); ok { providerErr := &fantasy.ProviderError{ - Title: cmp.Or(fantasy.ErrorTitleForStatusCode(apiErr.StatusCode), "provider request failed"), + Title: apiErrorTitle(apiErr), Message: apiErr.Error(), Cause: apiErr, URL: apiErr.Request.URL.String(), @@ -33,6 +32,8 @@ func toProviderErr(err error) error { RequestBody: apiErr.DumpRequest(true), ResponseHeaders: toHeaderMap(apiErr.Response.Header), ResponseBody: apiErr.DumpResponse(true), + ErrorType: apiErrorType(apiErr), + TransientError: fantasy.TransientStreamErrorTypes[apiErrorType(apiErr)], } parseContextTooLargeError(apiErr.Error(), providerErr) @@ -57,6 +58,26 @@ func toProviderErr(err error) error { return fantasy.WrapTransportError(err) } +// apiErrorTitle names what the error describes rather than the exchange that +// carried it, preferring the status, then the payload's type, then a generic +// title. A mid-stream SSE event rides inside a response that already went out +// as a 2xx, so titling from that status puts "ok" above a failure, and only +// the payload can name that one. +func apiErrorTitle(apiErr *anthropic.Error) string { + errType := apiErrorType(apiErr) + if apiErr.StatusCode >= http.StatusOK && apiErr.StatusCode < http.StatusMultipleChoices { + return streamErrorTitle(errType) + } + // Anthropic answers an overload with 529, which the HTTP registry does + // not name, so a status-derived title comes out empty and the payload's + // type is the only thing left that says what happened. + statusTitle := fantasy.ErrorTitleForStatusCode(apiErr.StatusCode) + if statusTitle == "" && errType != "" { + return streamErrorTitle(errType) + } + return cmp.Or(statusTitle, "provider request failed") +} + // streamErrorPrefix is the message prefix the Anthropic SDK uses for a // mid-stream `error` event. The SDK reports it via fmt.Errorf with no // exported type, so we match the prefix and parse the payload ourselves. @@ -72,6 +93,17 @@ type streamErrorEnvelope struct { } `json:"error"` } +// apiErrorType reads the error type from an API error body. The coder +// anthropic-sdk-go fork predates the SDK's Error.Type accessor, so this +// decodes the same {"error":{"type":"..."}} envelope that accessor reads. +func apiErrorType(apiErr *anthropic.Error) string { + var envelope streamErrorEnvelope + if json.Unmarshal([]byte(apiErr.RawJSON()), &envelope) != nil || envelope.Error == nil { + return "" + } + return envelope.Error.Type +} + // wrapStreamError classifies a mid-stream SSE error event as a // ProviderError, marking it transient when the payload names a temporary // server-side condition. Returns nil if err is not a stream error event. @@ -98,6 +130,7 @@ func wrapStreamError(err error) *fantasy.ProviderError { Message: cmp.Or(message, payload), Cause: err, ResponseBody: []byte(payload), + ErrorType: errType, TransientError: fantasy.TransientStreamErrorTypes[errType], } } diff --git a/providers/anthropic/error_test.go b/providers/anthropic/error_test.go index 02a897bd5..77fd84f81 100644 --- a/providers/anthropic/error_test.go +++ b/providers/anthropic/error_test.go @@ -1,12 +1,15 @@ package anthropic import ( + "context" "errors" "fmt" "io" + "net/http" "testing" "charm.land/fantasy" + "github.com/charmbracelet/anthropic-sdk-go" ) func TestToProviderErr_WrapsUnexpectedEOF(t *testing.T) { @@ -28,8 +31,8 @@ func TestToProviderErr_WrapsUnexpectedEOF(t *testing.T) { got := toProviderErr(tc.err) - var providerErr *fantasy.ProviderError - if !errors.As(got, &providerErr) { + providerErr, ok := errors.AsType[*fantasy.ProviderError](got) + if !ok { t.Fatalf("toProviderErr did not wrap %v as *fantasy.ProviderError (got %T)", tc.err, got) } if !errors.Is(providerErr.Cause, io.ErrUnexpectedEOF) { @@ -60,8 +63,7 @@ func TestToProviderErr_PassesThroughPlainEOF(t *testing.T) { // calls toProviderErr with io.EOF. But if it ever did, we should not // wrap it: io.EOF is not "retryable" in the ProviderError sense. got := toProviderErr(io.EOF) - var providerErr *fantasy.ProviderError - if errors.As(got, &providerErr) { + if _, ok := errors.AsType[*fantasy.ProviderError](got); ok { t.Errorf("toProviderErr wrapped io.EOF as ProviderError; should pass through") } } @@ -81,8 +83,8 @@ func TestToProviderErr_FlagsExpiredBedrockCredentials(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - var providerErr *fantasy.ProviderError - if !errors.As(toProviderErr(tc.err), &providerErr) { + providerErr, ok := errors.AsType[*fantasy.ProviderError](toProviderErr(tc.err)) + if !ok { t.Fatalf("toProviderErr did not wrap %v as *fantasy.ProviderError", tc.err) } if !providerErr.AuthError { @@ -99,8 +101,8 @@ func TestToProviderErr_RetriesMidStreamOverload(t *testing.T) { err := errors.New(`received error while streaming: {"type":"error","error":{"details":null,"type":"overloaded_error","message":"Overloaded"}}`) - var providerErr *fantasy.ProviderError - if !errors.As(toProviderErr(err), &providerErr) { + providerErr, ok := errors.AsType[*fantasy.ProviderError](toProviderErr(err)) + if !ok { t.Fatalf("toProviderErr did not wrap %v as *fantasy.ProviderError", err) } if !providerErr.IsRetryable() { @@ -132,8 +134,8 @@ func TestToProviderErr_StreamErrorTransientTypes(t *testing.T) { err := errors.New(`received error while streaming: {"type":"error","error":{"type":"` + errType + `","message":"transient"}}`) - var providerErr *fantasy.ProviderError - if !errors.As(toProviderErr(err), &providerErr) { + providerErr, ok := errors.AsType[*fantasy.ProviderError](toProviderErr(err)) + if !ok { t.Fatalf("toProviderErr did not wrap %v as *fantasy.ProviderError", err) } if !providerErr.IsRetryable() { @@ -148,8 +150,8 @@ func TestToProviderErr_PermanentStreamErrorNotRetried(t *testing.T) { err := errors.New(`received error while streaming: {"type":"error","error":{"type":"invalid_request_error","message":"bad tool schema"}}`) - var providerErr *fantasy.ProviderError - if !errors.As(toProviderErr(err), &providerErr) { + providerErr, ok := errors.AsType[*fantasy.ProviderError](toProviderErr(err)) + if !ok { t.Fatalf("toProviderErr did not wrap %v as *fantasy.ProviderError", err) } if providerErr.IsRetryable() { @@ -166,11 +168,200 @@ func TestToProviderErr_StreamErrorWrappedByOuterError(t *testing.T) { inner := errors.New(`received error while streaming: {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}`) err := fmt.Errorf("anthropic: %w", inner) - var providerErr *fantasy.ProviderError - if !errors.As(toProviderErr(err), &providerErr) { + providerErr, ok := errors.AsType[*fantasy.ProviderError](toProviderErr(err)) + if !ok { t.Fatalf("toProviderErr did not wrap %v as *fantasy.ProviderError", err) } if !providerErr.IsRetryable() { t.Error("a wrapped mid-stream overload must still be retryable") } } + +// The same overload, arriving the way Anthropic actually sends it. +// +// Anthropic accepts the request and answers 200, opens the stream, and only +// then sends an overloaded_error event down it. The SDK turns that event into +// a typed error carrying the status of the response it arrived in, so the +// status says the exchange succeeded while the body says the provider could +// not serve it. Titling from the status put "ok" above the error. +// +// Driving a real stream rather than hand-building the SDK error is the point: +// the status on a mid-stream event is the SDK's behaviour, not ours, and a +// hand-built error would assume the very thing worth checking. The pinned +// coder anthropic-sdk-go fork still reports the event as an untyped error, so +// the title comes from wrapStreamError and no status is set. +func TestToProviderErr_TitlesMidStreamOverloadFromThePayload(t *testing.T) { + t.Parallel() + + server, _ := newAnthropicStreamingServer([]string{ + anthropicSSEEvent("message_start", `{"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"stop_reason":null,"usage":{"input_tokens":1,"output_tokens":0}}}`), + anthropicSSEEvent("error", `{"type":"error","error":{"details":null,"type":"overloaded_error","message":"Overloaded"}}`), + }) + defer server.Close() + + provider, err := New(WithAPIKey("test-api-key"), WithBaseURL(server.URL)) + if err != nil { + t.Fatalf("New: %v", err) + } + model, err := provider.LanguageModel(context.Background(), "claude-sonnet-4-20250514") + if err != nil { + t.Fatalf("LanguageModel: %v", err) + } + stream, err := model.Stream(context.Background(), fantasy.Call{Prompt: testPrompt()}) + if err != nil { + t.Fatalf("Stream: %v", err) + } + + var streamErr error + for _, part := range collectAnthropicStreamParts(stream) { + if part.Type == fantasy.StreamPartTypeError { + streamErr = part.Error + } + } + if streamErr == nil { + t.Fatal("the stream reported no error, so the overload never surfaced") + } + + providerErr, ok := errors.AsType[*fantasy.ProviderError](streamErr) + if !ok { + t.Fatalf("stream error is %T, want *fantasy.ProviderError", streamErr) + } + + if providerErr.Title != "provider overloaded" { + t.Errorf("Title = %q, want %q", providerErr.Title, "provider overloaded") + } + if !providerErr.TransientError { + t.Error("TransientError must stay set so the step is retried") + } + if !providerErr.IsRetryable() { + t.Error("a mid-stream overload must stay retryable") + } +} + +// The status names the failure where it can, since "too many requests" beats +// anything derived from a body. Where it cannot, the payload's type is the +// next best thing and a generic title is the last resort. +func TestAPIErrorTitle(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + status int + errType string + want string + }{ + {"a named failure status wins", http.StatusTooManyRequests, "rate_limit_error", "too many requests"}, + {"even over a type that disagrees", http.StatusUnauthorized, "overloaded_error", "unauthorized"}, + {"a named status needs no type", http.StatusInternalServerError, "", "internal server error"}, + + // Anthropic answers an overload with 529, which the HTTP registry + // does not name, so without the payload this reads "provider request + // failed" while the error says overloaded_error two fields away. + {"an unnamed status defers to the type", 529, "overloaded_error", "provider overloaded"}, + {"an unnamed status with no type is generic", 529, "", "provider request failed"}, + + // A 2xx carried a mid-stream failure: its status describes the + // exchange, so only the payload can name the error. + {"a 2xx never titles from the status", http.StatusOK, "overloaded_error", "provider overloaded"}, + {"a 2xx with no type says so", http.StatusOK, "", "provider stream error"}, + + {"no HTTP exchange happened", 0, "", "provider request failed"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if got := apiErrorTitle(apiErrorWithType(t, tc.status, tc.errType)); got != tc.want { + t.Errorf("apiErrorTitle(status %d, type %q) = %q, want %q", + tc.status, tc.errType, got, tc.want) + } + }) + } +} + +// apiErrorWithType builds an SDK error carrying errType. It has to arrive the +// way the SDK fills it: by decoding an error envelope. +func apiErrorWithType(t *testing.T, status int, errType string) *anthropic.Error { + t.Helper() + + apiErr := &anthropic.Error{} + if errType != "" { + envelope := fmt.Sprintf(`{"type":"error","error":{"type":%q,"message":"x"}}`, errType) + if err := apiErr.UnmarshalJSON([]byte(envelope)); err != nil { + t.Fatalf("UnmarshalJSON(%s): %v", envelope, err) + } + if got := apiErrorType(apiErr); got != errType { + t.Fatalf("the SDK did not take the type: apiErrorType() = %q, want %q", got, errType) + } + } + apiErr.StatusCode = status + return apiErr +} + +// ErrorType carries the provider's own name for the failure so callers do not +// have to read it back out of Message, which is formatted for people. +func TestToProviderErr_CarriesTheErrorType(t *testing.T) { + t.Parallel() + + t.Run("typed SDK error", func(t *testing.T) { + t.Parallel() + + server, _ := newAnthropicStreamingServer([]string{ + anthropicSSEEvent("message_start", `{"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"stop_reason":null,"usage":{"input_tokens":1,"output_tokens":0}}}`), + anthropicSSEEvent("error", `{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}`), + }) + defer server.Close() + + providerErr := streamProviderError(t, server.URL) + if providerErr.ErrorType != "overloaded_error" { + t.Errorf("ErrorType = %q, want %q", providerErr.ErrorType, "overloaded_error") + } + }) + + t.Run("untyped stream error", func(t *testing.T) { + t.Parallel() + + err := errors.New(streamErrorPrefix + ` {"type":"error","error":{"type":"api_error","message":"Internal"}}`) + + providerErr, ok := errors.AsType[*fantasy.ProviderError](toProviderErr(err)) + if !ok { + t.Fatalf("toProviderErr did not wrap %v as *fantasy.ProviderError", err) + } + if providerErr.ErrorType != "api_error" { + t.Errorf("ErrorType = %q, want %q", providerErr.ErrorType, "api_error") + } + }) +} + +// streamProviderError drives one stream against url and returns the +// ProviderError it reported. +func streamProviderError(t *testing.T, url string) *fantasy.ProviderError { + t.Helper() + + provider, err := New(WithAPIKey("test-api-key"), WithBaseURL(url)) + if err != nil { + t.Fatalf("New: %v", err) + } + model, err := provider.LanguageModel(context.Background(), "claude-sonnet-4-20250514") + if err != nil { + t.Fatalf("LanguageModel: %v", err) + } + stream, err := model.Stream(context.Background(), fantasy.Call{Prompt: testPrompt()}) + if err != nil { + t.Fatalf("Stream: %v", err) + } + + var streamErr error + for _, part := range collectAnthropicStreamParts(stream) { + if part.Type == fantasy.StreamPartTypeError { + streamErr = part.Error + } + } + if streamErr == nil { + t.Fatal("the stream reported no error") + } + providerErr, ok := errors.AsType[*fantasy.ProviderError](streamErr) + if !ok { + t.Fatalf("stream error is %T, want *fantasy.ProviderError", streamErr) + } + return providerErr +} diff --git a/providers/anthropic/provider_options.go b/providers/anthropic/provider_options.go index 9a077a2b0..2e1d5963d 100644 --- a/providers/anthropic/provider_options.go +++ b/providers/anthropic/provider_options.go @@ -266,6 +266,28 @@ type CacheControl struct { Type string `json:"type"` } +// CacheType returns the configured cache type, or the empty string when no +// cache control is set, so callers can tag a message without a nil check. +func (c *CacheControl) CacheType() string { + if c == nil { + return "" + } + return c.Type +} + +// ToolResultCacheType returns the prompt-cache hint for content part i of a +// tool message, falling back to the message's own options on the last part. +func ToolResultCacheType(msg fantasy.Message, i int) string { + if i < 0 || i >= len(msg.Content) { + return "" + } + cacheControl := GetCacheControl(msg.Content[i].Options()) + if cacheControl == nil && i == len(msg.Content)-1 { + cacheControl = GetCacheControl(msg.ProviderOptions) + } + return cacheControl.CacheType() +} + // NewProviderOptions creates new provider options for the Anthropic provider. func NewProviderOptions(opts *ProviderOptions) fantasy.ProviderOptions { return fantasy.ProviderOptions{ diff --git a/providers/anthropic/stream_open_blocks_test.go b/providers/anthropic/stream_open_blocks_test.go new file mode 100644 index 000000000..37e6c6ea1 --- /dev/null +++ b/providers/anthropic/stream_open_blocks_test.go @@ -0,0 +1,229 @@ +package anthropic + +import ( + "context" + "testing" + + "charm.land/fantasy" + "github.com/charmbracelet/anthropic-sdk-go" + "github.com/stretchr/testify/require" +) + +// A tool call must be reported even when its block is never ended: +// max_tokens truncation omits content_block_stop, and the accumulator drops +// a block whose start it could not index. +func TestStream_ReportsToolCallsLeftOpenByTheProvider(t *testing.T) { + t.Parallel() + + messageStart := anthropicSSEEvent("message_start", `{"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"stop_reason":null,"usage":{"input_tokens":1,"output_tokens":0}}}`) + toolStart := anthropicSSEEvent("content_block_start", `{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_1","name":"write","input":{}}}`) + partialArgs := anthropicSSEEvent("content_block_delta", `{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"path\":\"a.go\",\"content\":\"pack"}}`) + toolStop := anthropicSSEEvent("content_block_stop", `{"type":"content_block_stop","index":0}`) + maxTokens := anthropicSSEEvent("message_delta", `{"type":"message_delta","delta":{"stop_reason":"max_tokens","stop_sequence":null},"usage":{"output_tokens":64000}}`) + toolUse := anthropicSSEEvent("message_delta", `{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"output_tokens":20}}`) + messageStop := anthropicSSEEvent("message_stop", `{"type":"message_stop"}`) + + // A start event at an unexpected index is dropped by the accumulator. + skewedStart := anthropicSSEEvent("content_block_start", `{"type":"content_block_start","index":3,"content_block":{"type":"tool_use","id":"toolu_2","name":"write","input":{}}}`) + skewedArgs := anthropicSSEEvent("content_block_delta", `{"type":"content_block_delta","index":3,"delta":{"type":"input_json_delta","partial_json":"{\"path\":\"b.go\"}"}}`) + skewedStop := anthropicSSEEvent("content_block_stop", `{"type":"content_block_stop","index":3}`) + + tests := []struct { + name string + chunks []string + wantInput string + wantFinish fantasy.FinishReason + wantToolName string + }{ + { + name: "complete tool call", + chunks: []string{messageStart, toolStart, partialArgs, toolStop, toolUse, messageStop}, + wantInput: `{"path":"a.go","content":"pack`, + wantFinish: fantasy.FinishReasonToolCalls, + wantToolName: "write", + }, + { + name: "tool call truncated at max_tokens", + chunks: []string{messageStart, toolStart, partialArgs, maxTokens, messageStop}, + wantInput: `{"path":"a.go","content":"pack`, + wantFinish: fantasy.FinishReasonLength, + wantToolName: "write", + }, + { + name: "tool call the accumulator could not index", + chunks: []string{messageStart, skewedStart, skewedArgs, skewedStop, toolUse, messageStop}, + wantInput: `{"path":"b.go"}`, + wantFinish: fantasy.FinishReasonToolCalls, + wantToolName: "write", + }, + { + // No parameters means no deltas, so the arguments come from the + // fallback. + name: "tool call with no arguments", + chunks: []string{messageStart, toolStart, toolStop, toolUse, messageStop}, + wantInput: `{}`, + wantFinish: fantasy.FinishReasonToolCalls, + wantToolName: "write", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + server, _ := newAnthropicStreamingServer(tt.chunks) + defer server.Close() + + provider, err := New(WithAPIKey("test-api-key"), WithBaseURL(server.URL)) + require.NoError(t, err) + model, err := provider.LanguageModel(context.Background(), "claude-sonnet-4-20250514") + require.NoError(t, err) + + stream, err := model.Stream(context.Background(), fantasy.Call{Prompt: testPrompt()}) + require.NoError(t, err) + + var ( + toolCalls []fantasy.StreamPart + sawInputEnd bool + finishReason fantasy.FinishReason + streamErr error + ) + for part := range stream { + switch part.Type { + case fantasy.StreamPartTypeToolCall: + toolCalls = append(toolCalls, part) + case fantasy.StreamPartTypeToolInputEnd: + sawInputEnd = true + case fantasy.StreamPartTypeFinish: + finishReason = part.FinishReason + case fantasy.StreamPartTypeError: + streamErr = part.Error + } + } + + require.NoError(t, streamErr) + require.True(t, sawInputEnd, "a started tool call must report the end of its input") + require.Len(t, toolCalls, 1, "the tool call must be reported exactly once") + require.Equal(t, tt.wantToolName, toolCalls[0].ToolCallName) + require.Equal(t, tt.wantInput, toolCalls[0].ToolCallInput) + require.Equal(t, tt.wantFinish, finishReason) + }) + } +} + +// Abandoning iteration while blocks are still open must not panic or emit +// anything further. +func TestStream_StopsCleanlyWithOpenToolCall(t *testing.T) { + t.Parallel() + + chunks := []string{ + anthropicSSEEvent("message_start", `{"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"stop_reason":null,"usage":{"input_tokens":1,"output_tokens":0}}}`), + anthropicSSEEvent("content_block_start", `{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_1","name":"write","input":{}}}`), + anthropicSSEEvent("content_block_delta", `{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"path\":"}}`), + anthropicSSEEvent("message_delta", `{"type":"message_delta","delta":{"stop_reason":"max_tokens","stop_sequence":null},"usage":{"output_tokens":9}}`), + anthropicSSEEvent("message_stop", `{"type":"message_stop"}`), + } + + server, _ := newAnthropicStreamingServer(chunks) + defer server.Close() + + provider, err := New(WithAPIKey("test-api-key"), WithBaseURL(server.URL)) + require.NoError(t, err) + model, err := provider.LanguageModel(context.Background(), "claude-sonnet-4-20250514") + require.NoError(t, err) + + stream, err := model.Stream(context.Background(), fantasy.Call{Prompt: testPrompt()}) + require.NoError(t, err) + + var seen int + for range stream { + seen++ + break + } + require.Equal(t, 1, seen) +} + +// The accumulator is indexed by position, and a block that could not be +// indexed leaves the positions of everything after it shifted. The fallback +// therefore has to confirm identity rather than trust position alone. +// +// This exercises arguments directly. Reaching the identity check through a +// stream is not currently possible: a dropped block only ever makes +// acc.Content shorter, so a drifted index fails the bounds check first. The +// check guards the contract rather than a reachable bug, and testing it at +// this level says so honestly instead of dressing it up as a stream case. +func TestOpenToolBlockArguments(t *testing.T) { + t.Parallel() + + mine := anthropic.ContentBlockUnion{Type: "tool_use", ID: "toolu_1", Input: []byte(`{"path":"a.go"}`)} + theirs := anthropic.ContentBlockUnion{Type: "tool_use", ID: "toolu_other", Input: []byte(`{"path":"other.go"}`)} + + tests := []struct { + name string + deltas string + content []anthropic.ContentBlockUnion + index int64 + want string + }{ + { + name: "deltas win over the accumulator", + deltas: `{"path":"from-delta.go"}`, + // Even a matching block does not override what was streamed. + content: []anthropic.ContentBlockUnion{mine}, + index: 0, + want: `{"path":"from-delta.go"}`, + }, + { + name: "falls back to the block with this call's ID", + content: []anthropic.ContentBlockUnion{mine}, + index: 0, + want: `{"path":"a.go"}`, + }, + { + name: "refuses a block belonging to another call", + // Position says yes, identity says no. Handing over another + // call's arguments would tell the model it asked for something + // it never named, which is worse than reporting none. + content: []anthropic.ContentBlockUnion{theirs}, + index: 0, + want: `{}`, + }, + { + name: "index past the end", + content: []anthropic.ContentBlockUnion{mine}, + index: 3, + want: `{}`, + }, + { + name: "negative index", + content: nil, + index: -1, + want: `{}`, + }, + { + name: "empty accumulator", + content: nil, + index: 0, + want: `{}`, + }, + { + name: "matching block with no input", + content: []anthropic.ContentBlockUnion{{Type: "tool_use", ID: "toolu_1"}}, + index: 0, + want: `{}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + block := &openToolBlock{id: "toolu_1", name: "write"} + if tt.deltas != "" { + block.input.WriteString(tt.deltas) + } + got := block.arguments(anthropic.Message{Content: tt.content}, tt.index) + require.Equal(t, tt.want, got) + }) + } +} diff --git a/providers/azure/azure.go b/providers/azure/azure.go index 7435198dc..3d5604d0f 100644 --- a/providers/azure/azure.go +++ b/providers/azure/azure.go @@ -8,7 +8,6 @@ import ( "charm.land/fantasy" "charm.land/fantasy/providers/openai" - "github.com/openai/openai-go/v3/azure" "github.com/openai/openai-go/v3/option" ) @@ -52,7 +51,8 @@ func New(opts ...Option) (fantasy.Provider, error) { openai.WithName(Name), openai.WithBaseURL(o.baseURL), openai.WithSDKOptions( - azure.WithAPIKey(o.apiKey), + option.WithHeaderDel("Authorization"), + option.WithHeader("Api-Key", o.apiKey), ), )..., ) diff --git a/providers/google/error.go b/providers/google/error.go index 05a17666e..6a7163c9b 100644 --- a/providers/google/error.go +++ b/providers/google/error.go @@ -13,8 +13,8 @@ import ( var googleContextPattern = regexp.MustCompile(`input token count.*?(\d+).*?exceeds.*?maximum.*?(\d+)`) func toProviderErr(err error) error { - var apiErr genai.APIError - if !errors.As(err, &apiErr) { + apiErr, ok := errors.AsType[genai.APIError](err) + if !ok { // Wrap transient transport failures so `.IsRetryable()` works. return fantasy.WrapTransportError(err) } @@ -25,6 +25,7 @@ func toProviderErr(err error) error { Cause: err, StatusCode: apiErr.Code, ResponseBody: []byte(apiErr.Message), + ErrorType: apiErr.Status, } parseContextTooLargeError(apiErr.Message, providerErr) diff --git a/providers/google/error_test.go b/providers/google/error_test.go index 693f35ee2..4ea01104c 100644 --- a/providers/google/error_test.go +++ b/providers/google/error_test.go @@ -7,6 +7,7 @@ import ( "testing" "charm.land/fantasy" + "google.golang.org/genai" ) func TestToProviderErr_WrapsUnexpectedEOF(t *testing.T) { @@ -28,8 +29,8 @@ func TestToProviderErr_WrapsUnexpectedEOF(t *testing.T) { got := toProviderErr(tc.err) - var providerErr *fantasy.ProviderError - if !errors.As(got, &providerErr) { + providerErr, ok := errors.AsType[*fantasy.ProviderError](got) + if !ok { t.Fatalf("toProviderErr did not wrap %v as *fantasy.ProviderError (got %T)", tc.err, got) } if !errors.Is(providerErr.Cause, io.ErrUnexpectedEOF) { @@ -56,8 +57,27 @@ func TestToProviderErr_PassesThroughPlainEOF(t *testing.T) { t.Parallel() got := toProviderErr(io.EOF) - var providerErr *fantasy.ProviderError - if errors.As(got, &providerErr) { + if _, ok := errors.AsType[*fantasy.ProviderError](got); ok { t.Errorf("toProviderErr wrapped io.EOF as ProviderError; should pass through") } } + +// ErrorType carries Google's own name for the failure, so a caller can tell a +// quota refusal from a bad request without matching on prose. +func TestToProviderErr_CarriesTheErrorType(t *testing.T) { + t.Parallel() + + apiErr := genai.APIError{ + Code: 429, + Status: "RESOURCE_EXHAUSTED", + Message: "Resource has been exhausted", + } + + providerErr, ok := errors.AsType[*fantasy.ProviderError](toProviderErr(apiErr)) + if !ok { + t.Fatalf("toProviderErr did not wrap %v as *fantasy.ProviderError", apiErr) + } + if providerErr.ErrorType != "RESOURCE_EXHAUSTED" { + t.Errorf("ErrorType = %q, want %q", providerErr.ErrorType, "RESOURCE_EXHAUSTED") + } +} diff --git a/providers/google/google.go b/providers/google/google.go index c6b593c13..6508af16c 100644 --- a/providers/google/google.go +++ b/providers/google/google.go @@ -892,7 +892,7 @@ func (g *languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy. // we need to make sure that there is actual tokendata if resp.UsageMetadata != nil && resp.UsageMetadata.TotalTokenCount != 0 { - currentUsage := mapUsage(resp.UsageMetadata) + currentUsage := g.mapUsage(resp.UsageMetadata) // if first usage chunk if usage == nil { usage = ¤tUsage @@ -1170,7 +1170,7 @@ func (g *languageModel) streamObjectWithJSONMode(ctx context.Context, call fanta // we need to make sure that there is actual tokendata if resp.UsageMetadata != nil && resp.UsageMetadata.TotalTokenCount != 0 { - currentUsage := mapUsage(resp.UsageMetadata) + currentUsage := g.mapUsage(resp.UsageMetadata) if usage == nil { usage = ¤tUsage } else { @@ -1187,7 +1187,19 @@ func (g *languageModel) streamObjectWithJSONMode(ctx context.Context, call fanta // Final validation and emit if streamErr == nil && lastParsedObject != nil { - finishReason := cmp.Or(lastFinishReason, fantasy.FinishReasonStop) + if lastFinishReason == "" { + // Truncated stream: no candidate emitted a finishReason + // before close. Reporting it as "stop" would hand the caller + // a cut-short object that looks complete; surface a retryable + // error instead, as Stream does. + yield(fantasy.ObjectStreamPart{ + Type: fantasy.ObjectStreamPartTypeError, + Error: fantasy.NewIncompleteStreamError(), + }) + return + } + + finishReason := lastFinishReason var finalUsage fantasy.Usage if usage != nil { @@ -1482,7 +1494,7 @@ func (g languageModel) mapResponse(response *genai.GenerateContentResponse, warn return &fantasy.Response{ Content: content, - Usage: mapUsage(response.UsageMetadata), + Usage: g.mapUsage(response.UsageMetadata), FinishReason: finishReason, Warnings: warnings, }, nil @@ -1521,12 +1533,42 @@ func mapFinishReason(reason genai.FinishReason) fantasy.FinishReason { } } -func mapUsage(usage *genai.GenerateContentResponseUsageMetadata) fantasy.Usage { +// mapUsage maps Google's usage metadata to fantasy usage. +// +// Output tokens always include thoughts: downstream pricing never itemizes +// reasoning, so a surface that reports thoughts disjointly must be folded in +// here. Whether candidatesTokenCount already contains thoughtsTokenCount +// depends on the surface — AI Studio folds them in, Vertex reports them +// apart, and preview models have flipped between the two — so the totals +// decide per response: +// +// prompt + candidates == total → candidates already include thoughts +// prompt + candidates + thoughts == total → thoughts are disjoint; add them +// +// toolUsePromptTokenCount counts as prompt in both sums. When neither +// equality holds (partial metadata), fall back to the backend's documented +// behavior: Vertex disjoint, AI Studio inclusive. +func (g languageModel) mapUsage(usage *genai.GenerateContentResponseUsageMetadata) fantasy.Usage { + output := int64(usage.CandidatesTokenCount) + reasoning := int64(usage.ThoughtsTokenCount) + if reasoning > 0 { + prompt := int64(usage.PromptTokenCount) + int64(usage.ToolUsePromptTokenCount) + total := int64(usage.TotalTokenCount) + switch { + case prompt+output == total: + // Candidates already include thoughts (AI Studio). + case prompt+output+reasoning == total: + // Thoughts are reported disjointly (Vertex). + output += reasoning + case g.providerOptions.backend == genai.BackendVertexAI: + output += reasoning + } + } return fantasy.Usage{ InputTokens: int64(usage.PromptTokenCount), - OutputTokens: int64(usage.CandidatesTokenCount), + OutputTokens: output, TotalTokens: int64(usage.TotalTokenCount), - ReasoningTokens: int64(usage.ThoughtsTokenCount), + ReasoningTokens: reasoning, CacheCreationTokens: 0, CacheReadTokens: int64(usage.CachedContentTokenCount), } diff --git a/providers/google/object_stream_finish_test.go b/providers/google/object_stream_finish_test.go new file mode 100644 index 000000000..f9537ba19 --- /dev/null +++ b/providers/google/object_stream_finish_test.go @@ -0,0 +1,137 @@ +package google + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "charm.land/fantasy" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newJSONModeServer serves a Gemini JSON-mode structured-output stream. The +// candidate carries the schema-shaped text and, when finishReason is non-empty, +// that reason on a trailing chunk. An empty finishReason models an upstream +// that closed before it ever sent candidate.finishReason. +func newJSONModeServer(t *testing.T, finishReason string) *httptest.Server { + t.Helper() + + usage := map[string]any{ + "promptTokenCount": 5, + "candidatesTokenCount": 4, + "totalTokenCount": 9, + } + chunk := func(candidate map[string]any) map[string]any { + return map[string]any{"candidates": []map[string]any{candidate}, "usageMetadata": usage} + } + + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + body := chunk(map[string]any{ + "content": map[string]any{"role": "model", "parts": []map[string]any{ + {"text": `{"answer":"hello"}`}, + }}, + }) + encoded, err := json.Marshal(body) + require.NoError(t, err) + + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: " + string(encoded) + "\r\n\r\n")) + + if finishReason == "" { + return + } + last, err := json.Marshal(chunk(map[string]any{ + "content": map[string]any{"role": "model", "parts": []map[string]any{}}, + "finishReason": finishReason, + })) + require.NoError(t, err) + _, _ = w.Write([]byte("data: " + string(last) + "\r\n\r\n")) + })) +} + +func objectStreamModel(t *testing.T, baseURL string) fantasy.LanguageModel { + t.Helper() + + p, err := New(WithVertex("test-project", "us-central1"), WithBaseURL(baseURL), WithSkipAuth(true)) + require.NoError(t, err) + model, err := p.LanguageModel(t.Context(), "gemini-2.5-flash") + require.NoError(t, err) + return model +} + +func objectStreamCall() fantasy.ObjectCall { + return fantasy.ObjectCall{ + Prompt: fantasy.Prompt{{ + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{fantasy.TextPart{Text: "Hi"}}, + }}, + Schema: fantasy.Schema{ + Type: "object", + Properties: map[string]*fantasy.Schema{ + "answer": {Type: "string"}, + }, + Required: []string{"answer"}, + }, + } +} + +// A JSON-mode object stream that closed before any candidate carried a +// finishReason was reported as a clean "stop", so a truncated object looked +// like a completed one and the retry middleware never engaged. The main Stream +// path already surfaces a retryable incomplete-stream error for the same input +// (google.go), and the OpenAI adapter has a regression test for it +// (TestChatCompletionsStreamObject_RequiresFinishReasonBeforeFinish). +func TestStreamObjectWithoutFinishReasonIsIncomplete(t *testing.T) { + t.Parallel() + + server := newJSONModeServer(t, "") + defer server.Close() + + stream, err := objectStreamModel(t, server.URL).StreamObject(t.Context(), objectStreamCall()) + require.NoError(t, err) + + var finishes []fantasy.ObjectStreamPart + var errorParts []fantasy.ObjectStreamPart + for part := range stream { + switch part.Type { + case fantasy.ObjectStreamPartTypeFinish: + finishes = append(finishes, part) + case fantasy.ObjectStreamPartTypeError: + errorParts = append(errorParts, part) + } + } + + require.Len(t, errorParts, 1, "a stream with no candidate.finishReason must report an error") + assert.Empty(t, finishes, "a stream with no candidate.finishReason must not report finish") + + providerErr, ok := errors.AsType[*fantasy.ProviderError](errorParts[0].Error) + require.True(t, ok, "want a *fantasy.ProviderError, got %T", errorParts[0].Error) + assert.True(t, providerErr.IsRetryable(), "the incomplete-stream error must be retryable") +} + +// A stream that does carry a terminal reason must still finish normally. +func TestStreamObjectWithFinishReasonFinishes(t *testing.T) { + t.Parallel() + + server := newJSONModeServer(t, "STOP") + defer server.Close() + + stream, err := objectStreamModel(t, server.URL).StreamObject(t.Context(), objectStreamCall()) + require.NoError(t, err) + + var finishes []fantasy.ObjectStreamPart + for part := range stream { + switch part.Type { + case fantasy.ObjectStreamPartTypeFinish: + finishes = append(finishes, part) + case fantasy.ObjectStreamPartTypeError: + t.Fatalf("unexpected error part: %v", part.Error) + } + } + + require.Len(t, finishes, 1) + assert.Equal(t, fantasy.FinishReasonStop, finishes[0].FinishReason) +} diff --git a/providers/google/usage_test.go b/providers/google/usage_test.go new file mode 100644 index 000000000..83c13824b --- /dev/null +++ b/providers/google/usage_test.go @@ -0,0 +1,90 @@ +package google + +import ( + "testing" + + "google.golang.org/genai" +) + +func TestMapUsage(t *testing.T) { + t.Run("vertex shape: thoughts disjoint from candidates", func(t *testing.T) { + lm := languageModel{providerOptions: options{backend: genai.BackendVertexAI}} + u := lm.mapUsage(&genai.GenerateContentResponseUsageMetadata{ + PromptTokenCount: 100, + CandidatesTokenCount: 50, + ThoughtsTokenCount: 30, + TotalTokenCount: 180, + }) + if u.OutputTokens != 80 { + t.Fatalf("expected output 50+30=80, got %d", u.OutputTokens) + } + if u.ReasoningTokens != 30 { + t.Fatalf("expected reasoning 30, got %d", u.ReasoningTokens) + } + }) + + t.Run("ai studio shape: candidates include thoughts", func(t *testing.T) { + lm := languageModel{providerOptions: options{backend: genai.BackendGeminiAPI}} + u := lm.mapUsage(&genai.GenerateContentResponseUsageMetadata{ + PromptTokenCount: 100, + CandidatesTokenCount: 80, // 50 visible + 30 thoughts + ThoughtsTokenCount: 30, + TotalTokenCount: 180, + }) + if u.OutputTokens != 80 { + t.Fatalf("expected output unchanged at 80, got %d", u.OutputTokens) + } + if u.ReasoningTokens != 30 { + t.Fatalf("expected reasoning 30, got %d", u.ReasoningTokens) + } + }) + + t.Run("tool use prompt tokens count as prompt in the discriminant", func(t *testing.T) { + lm := languageModel{providerOptions: options{backend: genai.BackendGeminiAPI}} + u := lm.mapUsage(&genai.GenerateContentResponseUsageMetadata{ + PromptTokenCount: 100, + ToolUsePromptTokenCount: 20, + CandidatesTokenCount: 50, + ThoughtsTokenCount: 30, + TotalTokenCount: 200, // 120 + 50 + 30: disjoint + }) + if u.OutputTokens != 80 { + t.Fatalf("expected output 50+30=80, got %d", u.OutputTokens) + } + }) + + t.Run("no thoughts leaves output untouched", func(t *testing.T) { + lm := languageModel{providerOptions: options{backend: genai.BackendVertexAI}} + u := lm.mapUsage(&genai.GenerateContentResponseUsageMetadata{ + PromptTokenCount: 100, + CandidatesTokenCount: 50, + TotalTokenCount: 150, + }) + if u.OutputTokens != 50 { + t.Fatalf("expected output 50, got %d", u.OutputTokens) + } + if u.ReasoningTokens != 0 { + t.Fatalf("expected reasoning 0, got %d", u.ReasoningTokens) + } + }) + + t.Run("ambiguous totals fall back to the backend default", func(t *testing.T) { + // Neither equality holds (partial or inconsistent metadata). + metadata := &genai.GenerateContentResponseUsageMetadata{ + PromptTokenCount: 100, + CandidatesTokenCount: 50, + ThoughtsTokenCount: 30, + TotalTokenCount: 177, + } + + vertex := languageModel{providerOptions: options{backend: genai.BackendVertexAI}} + if u := vertex.mapUsage(metadata); u.OutputTokens != 80 { + t.Fatalf("vertex fallback: expected output 80, got %d", u.OutputTokens) + } + + studio := languageModel{providerOptions: options{backend: genai.BackendGeminiAPI}} + if u := studio.mapUsage(metadata); u.OutputTokens != 50 { + t.Fatalf("ai studio fallback: expected output 50, got %d", u.OutputTokens) + } + }) +} diff --git a/providers/internal/prompttest/cases.go b/providers/internal/prompttest/cases.go new file mode 100644 index 000000000..8ca5e9a09 --- /dev/null +++ b/providers/internal/prompttest/cases.go @@ -0,0 +1,372 @@ +package prompttest + +import ( + "encoding/base64" + + "charm.land/fantasy" + "charm.land/fantasy/providers/anthropic" +) + +// The corpus: one prompt per rule worth pinning, exercised against every +// chat-completions converter by Golden. + +func cacheControl() fantasy.ProviderOptions { + return fantasy.ProviderOptions{ + anthropic.Name: &anthropic.ProviderCacheControlOptions{ + CacheControl: anthropic.CacheControl{Type: "ephemeral"}, + }, + } +} + +func png() []byte { return []byte{0x89, 0x50, 0x4e, 0x47} } + +func b64(data []byte) string { return base64.StdEncoding.EncodeToString(data) } + +// Cases returns the shared corpus. Every converter should be able to handle +// every case; the point of the corpus is that the cases a converter handles +// badly show up in its golden files rather than going unnoticed. +func Cases() []Case { + return []Case{ + { + Name: "system_single_text", + Model: "gpt-4o", + Prompt: fantasy.Prompt{ + {Role: fantasy.MessageRoleSystem, Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "you are helpful"}, + }}, + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "hello"}, + }}, + }, + }, + { + Name: "system_multiple_text_parts", + Model: "gpt-4o", + Prompt: fantasy.Prompt{ + {Role: fantasy.MessageRoleSystem, Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "first"}, + fantasy.TextPart{Text: "second"}, + }}, + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "hello"}, + }}, + }, + }, + { + Name: "system_cache_control", + Model: "anthropic/claude-sonnet-4", + Prompt: fantasy.Prompt{ + { + Role: fantasy.MessageRoleSystem, + ProviderOptions: cacheControl(), + Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "cached system prompt"}, + }, + }, + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "hello"}, + }}, + }, + }, + { + Name: "user_text_and_image", + Model: "gpt-4o", + Prompt: fantasy.Prompt{ + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "what is this"}, + fantasy.FilePart{Filename: "a.png", MediaType: "image/png", Data: png()}, + }}, + }, + }, + { + Name: "user_pdf", + Model: "gpt-4o", + Prompt: fantasy.Prompt{ + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{ + fantasy.FilePart{Filename: "doc.pdf", MediaType: "application/pdf", Data: []byte("%PDF-1.4")}, + }}, + }, + }, + { + Name: "user_audio_wav", + Model: "gpt-4o-audio-preview", + Prompt: fantasy.Prompt{ + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{ + fantasy.FilePart{Filename: "a.wav", MediaType: "audio/wav", Data: []byte{1, 2, 3}}, + }}, + }, + }, + { + Name: "user_unsupported_media", + Model: "gpt-4o", + Prompt: fantasy.Prompt{ + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{ + fantasy.FilePart{Filename: "a.mp4", MediaType: "video/mp4", Data: []byte{1, 2, 3}}, + }}, + }, + }, + { + Name: "user_cache_control", + Model: "anthropic/claude-sonnet-4", + Prompt: fantasy.Prompt{ + { + Role: fantasy.MessageRoleUser, + ProviderOptions: cacheControl(), + Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "cached user turn"}, + }, + }, + }, + }, + { + // Every converter should drop this and warn: an empty user message + // is not valid input and silently forwarding it produces an API + // error far from the cause. + Name: "user_empty_is_dropped", + Model: "gpt-4o", + Prompt: fantasy.Prompt{ + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: ""}, + }}, + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "real turn"}, + }}, + }, + }, + { + Name: "assistant_text_only", + Model: "gpt-4o", + Prompt: fantasy.Prompt{ + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "hi"}, + }}, + {Role: fantasy.MessageRoleAssistant, Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "hello there"}, + }}, + }, + }, + { + Name: "assistant_text_and_tool_calls", + Model: "gpt-4o", + Prompt: fantasy.Prompt{ + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "look at these"}, + }}, + {Role: fantasy.MessageRoleAssistant, Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "on it"}, + fantasy.ToolCallPart{ToolCallID: "c1", ToolName: "view", Input: `{"path":"a"}`}, + fantasy.ToolCallPart{ToolCallID: "c2", ToolName: "view", Input: `{"path":"b"}`}, + }}, + {Role: fantasy.MessageRoleTool, Content: []fantasy.MessagePart{ + fantasy.ToolResultPart{ToolCallID: "c1", Output: fantasy.ToolResultOutputContentText{Text: "a body"}}, + }}, + {Role: fantasy.MessageRoleTool, Content: []fantasy.MessagePart{ + fantasy.ToolResultPart{ToolCallID: "c2", Output: fantasy.ToolResultOutputContentText{Text: "b body"}}, + }}, + }, + }, + { + // Every converter should drop this and warn. An assistant message + // with neither content nor tool_calls is rejected outright, and + // because it stays in history the rejection repeats on every + // later request (charmbracelet/crush#3794). + Name: "assistant_empty_is_dropped", + Model: "gpt-4o", + Prompt: fantasy.Prompt{ + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "hi"}, + }}, + {Role: fantasy.MessageRoleAssistant, Content: []fantasy.MessagePart{}}, + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "still here"}, + }}, + }, + }, + { + // The assistant turn is the one role whose cache hint travels on + // the message rather than on a content part, so it needs its own + // case to keep that path honest. + Name: "assistant_cache_control", + Model: "anthropic/claude-sonnet-4", + Prompt: fantasy.Prompt{ + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "hi"}, + }}, + { + Role: fantasy.MessageRoleAssistant, + ProviderOptions: cacheControl(), + Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "cached assistant turn"}, + }, + }, + }, + }, + { + Name: "assistant_reasoning_anthropic", + Model: "anthropic/claude-sonnet-4", + Prompt: fantasy.Prompt{ + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "think"}, + }}, + {Role: fantasy.MessageRoleAssistant, Content: []fantasy.MessagePart{ + fantasy.ReasoningPart{Text: "thinking about it"}, + fantasy.TextPart{Text: "the answer"}, + }}, + }, + }, + { + Name: "assistant_reasoning_only", + Model: "anthropic/claude-sonnet-4", + Prompt: fantasy.Prompt{ + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "think"}, + }}, + {Role: fantasy.MessageRoleAssistant, Content: []fantasy.MessagePart{ + fantasy.ReasoningPart{Text: "only reasoning, no text"}, + }}, + }, + }, + { + Name: "tool_result_error", + Model: "gpt-4o", + Prompt: fantasy.Prompt{ + {Role: fantasy.MessageRoleAssistant, Content: []fantasy.MessagePart{ + fantasy.ToolCallPart{ToolCallID: "c1", ToolName: "view", Input: "{}"}, + }}, + {Role: fantasy.MessageRoleTool, Content: []fantasy.MessagePart{ + fantasy.ToolResultPart{ToolCallID: "c1", Output: fantasy.ToolResultOutputContentError{Error: errFixture{}}}, + }}, + }, + }, + { + // The tool_call declared above must be answered. A converter that + // drops the media result leaves an unanswered tool_call_id, which + // strict backends reject. + Name: "tool_result_media_image", + Model: "gpt-4o", + Prompt: fantasy.Prompt{ + {Role: fantasy.MessageRoleAssistant, Content: []fantasy.MessagePart{ + fantasy.ToolCallPart{ToolCallID: "c1", ToolName: "view", Input: "{}"}, + }}, + {Role: fantasy.MessageRoleTool, Content: []fantasy.MessagePart{ + fantasy.ToolResultPart{ToolCallID: "c1", Output: fantasy.ToolResultOutputContentMedia{ + Data: b64(png()), + MediaType: "image/png", + }}, + }}, + }, + }, + { + // Accompanying text wins over the generated placeholder, so the + // tool message reads as the tool's own answer. + Name: "tool_result_media_image_with_text", + Model: "gpt-4o", + Prompt: fantasy.Prompt{ + {Role: fantasy.MessageRoleAssistant, Content: []fantasy.MessagePart{ + fantasy.ToolCallPart{ToolCallID: "c1", ToolName: "view", Input: "{}"}, + }}, + {Role: fantasy.MessageRoleTool, Content: []fantasy.MessagePart{ + fantasy.ToolResultPart{ToolCallID: "c1", Output: fantasy.ToolResultOutputContentMedia{ + Data: b64(png()), + MediaType: "image/png", + Text: "Screenshot of the blockquote element.", + }}, + }}, + }, + }, + { + // Audio results travel as an input_audio part, format "wav". + Name: "tool_result_media_audio_wav", + Model: "gpt-4o-audio-preview", + Prompt: fantasy.Prompt{ + {Role: fantasy.MessageRoleAssistant, Content: []fantasy.MessagePart{ + fantasy.ToolCallPart{ToolCallID: "c1", ToolName: "say", Input: "{}"}, + }}, + {Role: fantasy.MessageRoleTool, Content: []fantasy.MessagePart{ + fantasy.ToolResultPart{ToolCallID: "c1", Output: fantasy.ToolResultOutputContentMedia{ + Data: b64([]byte{1, 2, 3}), + MediaType: "audio/wav", + }}, + }}, + }, + }, + { + // audio/mpeg and audio/mp3 both map to format "mp3", the branch + // the wav case above does not reach. + Name: "tool_result_media_audio_mpeg", + Model: "gpt-4o-audio-preview", + Prompt: fantasy.Prompt{ + {Role: fantasy.MessageRoleAssistant, Content: []fantasy.MessagePart{ + fantasy.ToolCallPart{ToolCallID: "c1", ToolName: "say", Input: "{}"}, + }}, + {Role: fantasy.MessageRoleTool, Content: []fantasy.MessagePart{ + fantasy.ToolResultPart{ToolCallID: "c1", Output: fantasy.ToolResultOutputContentMedia{ + Data: b64([]byte{1, 2, 3}), + MediaType: "audio/mpeg", + }}, + }}, + }, + }, + { + // An unsupported media type still has to answer the tool_call, so + // the text message goes out alone with a warning naming the type. + Name: "tool_result_media_unsupported", + Model: "gpt-4o", + Prompt: fantasy.Prompt{ + {Role: fantasy.MessageRoleAssistant, Content: []fantasy.MessagePart{ + fantasy.ToolCallPart{ToolCallID: "c1", ToolName: "record", Input: "{}"}, + }}, + {Role: fantasy.MessageRoleTool, Content: []fantasy.MessagePart{ + fantasy.ToolResultPart{ToolCallID: "c1", Output: fantasy.ToolResultOutputContentMedia{ + Data: b64([]byte{1, 2, 3}), + MediaType: "video/mp4", + }}, + }}, + }, + }, + { + // The ordering case from the media fix: a parallel batch must keep + // its tool messages contiguous. + Name: "tool_result_media_parallel_batch", + Model: "gpt-4o", + Prompt: fantasy.Prompt{ + {Role: fantasy.MessageRoleAssistant, Content: []fantasy.MessagePart{ + fantasy.ToolCallPart{ToolCallID: "c1", ToolName: "view", Input: "{}"}, + fantasy.ToolCallPart{ToolCallID: "c2", ToolName: "view", Input: "{}"}, + }}, + {Role: fantasy.MessageRoleTool, Content: []fantasy.MessagePart{ + fantasy.ToolResultPart{ToolCallID: "c1", Output: fantasy.ToolResultOutputContentMedia{ + Data: b64(png()), + MediaType: "image/png", + }}, + }}, + {Role: fantasy.MessageRoleTool, Content: []fantasy.MessagePart{ + fantasy.ToolResultPart{ToolCallID: "c2", Output: fantasy.ToolResultOutputContentText{Text: "plain"}}, + }}, + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "next"}, + }}, + }, + }, + { + Name: "tool_result_cache_control", + Model: "anthropic/claude-sonnet-4", + Prompt: fantasy.Prompt{ + {Role: fantasy.MessageRoleAssistant, Content: []fantasy.MessagePart{ + fantasy.ToolCallPart{ToolCallID: "c1", ToolName: "view", Input: "{}"}, + }}, + { + Role: fantasy.MessageRoleTool, + ProviderOptions: cacheControl(), + Content: []fantasy.MessagePart{ + fantasy.ToolResultPart{ToolCallID: "c1", Output: fantasy.ToolResultOutputContentText{Text: "cached result"}}, + }, + }, + }, + }, + } +} + +type errFixture struct{} + +func (errFixture) Error() string { return "tool blew up" } diff --git a/providers/internal/prompttest/prompt_test.go b/providers/internal/prompttest/prompt_test.go new file mode 100644 index 000000000..cd5f8be3b --- /dev/null +++ b/providers/internal/prompttest/prompt_test.go @@ -0,0 +1,27 @@ +package prompttest_test + +import ( + "testing" + + "charm.land/fantasy/providers/internal/prompttest" + "charm.land/fantasy/providers/openai" + "charm.land/fantasy/providers/openaicompat" + "charm.land/fantasy/providers/openrouter" + "charm.land/fantasy/providers/vercel" +) + +// TestPrompt records every chat-completions converter's output for the shared +// corpus. One file per case holds all four providers, so a divergence between +// them is a difference between adjacent keys in the same file. +// +// Regenerate with: go test ./providers/internal/prompttest -update +func TestPrompt(t *testing.T) { + t.Parallel() + + prompttest.Golden(t, []prompttest.Converter{ + {Name: "openai", Convert: openai.DefaultToPrompt}, + {Name: "openaicompat", Convert: openaicompat.ToPromptFunc}, + {Name: "openrouter", Convert: openrouter.ToPrompt}, + {Name: "vercel", Convert: vercel.ToPrompt}, + }) +} diff --git a/providers/internal/prompttest/prompttest.go b/providers/internal/prompttest/prompttest.go new file mode 100644 index 000000000..1bd365229 --- /dev/null +++ b/providers/internal/prompttest/prompttest.go @@ -0,0 +1,195 @@ +// Package prompttest provides a shared corpus of prompts and a golden-file +// harness for the chat-completions prompt builders. +// +// Four providers (openai, openaicompat, openrouter, vercel) convert a +// fantasy.Prompt into the same OpenAI SDK message type, and each has its own +// copy of the conversion. One corpus runs through all four and records every +// converter's output for a case in a single file, testdata/.json, keyed +// by provider. The file is the behaviour matrix: a difference between +// providers is a difference between adjacent keys, visible without running a +// diff across four directories. +// +// Reviewing a change to any converter means reading the golden diff rather +// than the control flow, and a behaviour that only one provider has becomes +// visible instead of buried. Regenerate with: +// +// go test ./providers/internal/prompttest -update +package prompttest + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "charm.land/fantasy" + "charm.land/fantasy/providers/openai" +) + +var update = flag.Bool("update", false, "rewrite prompt golden files") + +// Case is one prompt fixture. Model is passed through to the converter because +// several providers branch on the model ID. +type Case struct { + Name string + Model string + Prompt fantasy.Prompt +} + +// Result is one converter's output for one case. Messages is stored as raw +// JSON so the harness records exactly what the SDK would put on the wire, +// including the extra fields providers attach for cache control and reasoning +// details. +type Result struct { + Messages json.RawMessage `json:"messages"` + Warnings []string `json:"warnings"` +} + +// Converter is one named prompt builder under test. +type Converter struct { + Name string + Convert openai.LanguageModelToPromptFunc +} + +// Golden runs every case through every converter and compares the recorded +// matrix against testdata, or rewrites it under -update. +// +// Warnings are recorded alongside the messages because dropping content +// silently and dropping it with a warning are very different behaviours, and +// only the record makes that difference visible. +func Golden(t *testing.T, converters []Converter) { + t.Helper() + + cases := Cases() + seen := make(map[string]bool, len(cases)) + for _, tc := range cases { + if seen[tc.Name] { + t.Fatalf("duplicate case name %q: both would write the same golden file", tc.Name) + } + seen[tc.Name] = true + } + + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + got := record(t, tc, converters) + path := filepath.Join(goldenDir, tc.Name+".json") + if *update { + if err := os.MkdirAll(goldenDir, 0o750); err != nil { + t.Fatalf("create golden dir: %v", err) + } + if err := os.WriteFile(path, got, 0o600); err != nil { + t.Fatalf("write golden: %v", err) + } + return + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read golden (run with -update to create): %v", err) + } + // record writes LF, so a CRLF checkout would mismatch every line + // while the diff rendered identically. + want = []byte(strings.ReplaceAll(string(want), "\r\n", "\n")) + if diff := lineDiff(string(want), string(got)); diff != "" { + t.Errorf("golden mismatch for %s:\n%s", tc.Name, diff) + } + }) + } + + t.Run("no_stale_goldens", func(t *testing.T) { + if *update { + t.Skip("rewriting goldens") + } + entries, err := os.ReadDir(goldenDir) + if err != nil { + t.Fatalf("read golden dir: %v", err) + } + for _, e := range entries { + if e.IsDir() || filepath.Ext(e.Name()) != ".json" { + continue + } + name := strings.TrimSuffix(e.Name(), ".json") + if !seen[name] { + t.Errorf("golden %s has no matching case; delete it or restore the case", e.Name()) + } + } + }) +} + +const goldenDir = "testdata" + +// record converts one case with every converter and marshals the matrix. +// +// Converters producing identical output share one entry, keyed by their +// comma-joined names, so agreement collapses to a single block and any file +// with more than one key names exactly who diverges. +func record(t *testing.T, tc Case, converters []Converter) []byte { + t.Helper() + + type group struct { + result Result + names []string + } + groups := map[string]*group{} + for _, c := range converters { + messages, warnings := c.Convert(tc.Prompt, c.Name, tc.Model) + raw, err := json.Marshal(messages) + if err != nil { + t.Fatalf("marshal %s messages: %v", c.Name, err) + } + texts := make([]string, 0, len(warnings)) + for _, w := range warnings { + texts = append(texts, string(w.Type)+": "+w.Message) + } + result := Result{Messages: raw, Warnings: texts} + key, err := json.Marshal(result) + if err != nil { + t.Fatalf("key %s result: %v", c.Name, err) + } + g, ok := groups[string(key)] + if !ok { + g = &group{result: result} + groups[string(key)] = g + } + g.names = append(g.names, c.Name) + } + + matrix := make(map[string]Result, len(groups)) + for _, g := range groups { + matrix[strings.Join(g.names, ",")] = g.result + } + got, err := json.MarshalIndent(matrix, "", " ") + if err != nil { + t.Fatalf("marshal matrix: %v", err) + } + return append(got, '\n') +} + +// lineDiff reports the differing lines between want and got, or "" when they +// match. Golden files here run to hundreds of base64-heavy lines, so printing +// both in full buries the one field that moved. +func lineDiff(want, got string) string { + if want == got { + return "" + } + wantLines, gotLines := strings.Split(want, "\n"), strings.Split(got, "\n") + var b strings.Builder + for i := 0; i < max(len(wantLines), len(gotLines)); i++ { + w, g := at(wantLines, i), at(gotLines, i) + if w == g { + continue + } + fmt.Fprintf(&b, " line %d:\n want: %s\n got: %s\n", i+1, w, g) + } + return b.String() +} + +func at(lines []string, i int) string { + if i < len(lines) { + return lines[i] + } + return "" +} diff --git a/providers/internal/prompttest/testdata/assistant_cache_control.json b/providers/internal/prompttest/testdata/assistant_cache_control.json new file mode 100644 index 000000000..c067f579e --- /dev/null +++ b/providers/internal/prompttest/testdata/assistant_cache_control.json @@ -0,0 +1,31 @@ +{ + "openai,openaicompat": { + "messages": [ + { + "content": "hi", + "role": "user" + }, + { + "content": "cached assistant turn", + "role": "assistant" + } + ], + "warnings": [] + }, + "openrouter,vercel": { + "messages": [ + { + "content": "hi", + "role": "user" + }, + { + "content": "cached assistant turn", + "role": "assistant", + "cache_control": { + "type": "ephemeral" + } + } + ], + "warnings": [] + } +} diff --git a/providers/internal/prompttest/testdata/assistant_empty_is_dropped.json b/providers/internal/prompttest/testdata/assistant_empty_is_dropped.json new file mode 100644 index 000000000..dba969e5f --- /dev/null +++ b/providers/internal/prompttest/testdata/assistant_empty_is_dropped.json @@ -0,0 +1,17 @@ +{ + "openai,openaicompat,openrouter,vercel": { + "messages": [ + { + "content": "hi", + "role": "user" + }, + { + "content": "still here", + "role": "user" + } + ], + "warnings": [ + "other: dropping empty assistant message (contains neither user-facing content nor tool calls)" + ] + } +} diff --git a/providers/internal/prompttest/testdata/assistant_reasoning_anthropic.json b/providers/internal/prompttest/testdata/assistant_reasoning_anthropic.json new file mode 100644 index 000000000..b3ca52703 --- /dev/null +++ b/providers/internal/prompttest/testdata/assistant_reasoning_anthropic.json @@ -0,0 +1,42 @@ +{ + "openai": { + "messages": [ + { + "content": "think", + "role": "user" + }, + { + "content": "the answer", + "role": "assistant" + } + ], + "warnings": [] + }, + "openaicompat": { + "messages": [ + { + "content": "think", + "role": "user" + }, + { + "content": "the answer", + "role": "assistant", + "reasoning_content": "thinking about it" + } + ], + "warnings": [] + }, + "openrouter,vercel": { + "messages": [ + { + "content": "think", + "role": "user" + }, + { + "content": "\u003cthoughts\u003ethinking about it\u003c/thoughts\u003e\nthe answer", + "role": "assistant" + } + ], + "warnings": [] + } +} diff --git a/providers/internal/prompttest/testdata/assistant_reasoning_only.json b/providers/internal/prompttest/testdata/assistant_reasoning_only.json new file mode 100644 index 000000000..a93c7ac26 --- /dev/null +++ b/providers/internal/prompttest/testdata/assistant_reasoning_only.json @@ -0,0 +1,26 @@ +{ + "openai,openaicompat": { + "messages": [ + { + "content": "think", + "role": "user" + } + ], + "warnings": [ + "other: dropping empty assistant message (contains neither user-facing content nor tool calls)" + ] + }, + "openrouter,vercel": { + "messages": [ + { + "content": "think", + "role": "user" + }, + { + "content": "\u003cthoughts\u003eonly reasoning, no text\u003c/thoughts\u003e", + "role": "assistant" + } + ], + "warnings": [] + } +} diff --git a/providers/internal/prompttest/testdata/assistant_text_and_tool_calls.json b/providers/internal/prompttest/testdata/assistant_text_and_tool_calls.json new file mode 100644 index 000000000..1f4e9e526 --- /dev/null +++ b/providers/internal/prompttest/testdata/assistant_text_and_tool_calls.json @@ -0,0 +1,43 @@ +{ + "openai,openaicompat,openrouter,vercel": { + "messages": [ + { + "content": "look at these", + "role": "user" + }, + { + "content": "on it", + "tool_calls": [ + { + "id": "c1", + "function": { + "arguments": "{\"path\":\"a\"}", + "name": "view" + }, + "type": "function" + }, + { + "id": "c2", + "function": { + "arguments": "{\"path\":\"b\"}", + "name": "view" + }, + "type": "function" + } + ], + "role": "assistant" + }, + { + "content": "a body", + "tool_call_id": "c1", + "role": "tool" + }, + { + "content": "b body", + "tool_call_id": "c2", + "role": "tool" + } + ], + "warnings": [] + } +} diff --git a/providers/internal/prompttest/testdata/assistant_text_only.json b/providers/internal/prompttest/testdata/assistant_text_only.json new file mode 100644 index 000000000..a2dc3ada1 --- /dev/null +++ b/providers/internal/prompttest/testdata/assistant_text_only.json @@ -0,0 +1,15 @@ +{ + "openai,openaicompat,openrouter,vercel": { + "messages": [ + { + "content": "hi", + "role": "user" + }, + { + "content": "hello there", + "role": "assistant" + } + ], + "warnings": [] + } +} diff --git a/providers/internal/prompttest/testdata/system_cache_control.json b/providers/internal/prompttest/testdata/system_cache_control.json new file mode 100644 index 000000000..6572e5d26 --- /dev/null +++ b/providers/internal/prompttest/testdata/system_cache_control.json @@ -0,0 +1,31 @@ +{ + "openai,openaicompat": { + "messages": [ + { + "content": "cached system prompt", + "role": "system" + }, + { + "content": "hello", + "role": "user" + } + ], + "warnings": [] + }, + "openrouter,vercel": { + "messages": [ + { + "content": "cached system prompt", + "role": "system", + "cache_control": { + "type": "ephemeral" + } + }, + { + "content": "hello", + "role": "user" + } + ], + "warnings": [] + } +} diff --git a/providers/internal/prompttest/testdata/system_multiple_text_parts.json b/providers/internal/prompttest/testdata/system_multiple_text_parts.json new file mode 100644 index 000000000..ada01dafc --- /dev/null +++ b/providers/internal/prompttest/testdata/system_multiple_text_parts.json @@ -0,0 +1,15 @@ +{ + "openai,openaicompat,openrouter,vercel": { + "messages": [ + { + "content": "first\nsecond", + "role": "system" + }, + { + "content": "hello", + "role": "user" + } + ], + "warnings": [] + } +} diff --git a/providers/internal/prompttest/testdata/system_single_text.json b/providers/internal/prompttest/testdata/system_single_text.json new file mode 100644 index 000000000..a5089cdac --- /dev/null +++ b/providers/internal/prompttest/testdata/system_single_text.json @@ -0,0 +1,15 @@ +{ + "openai,openaicompat,openrouter,vercel": { + "messages": [ + { + "content": "you are helpful", + "role": "system" + }, + { + "content": "hello", + "role": "user" + } + ], + "warnings": [] + } +} diff --git a/providers/internal/prompttest/testdata/tool_result_cache_control.json b/providers/internal/prompttest/testdata/tool_result_cache_control.json new file mode 100644 index 000000000..0aed84d19 --- /dev/null +++ b/providers/internal/prompttest/testdata/tool_result_cache_control.json @@ -0,0 +1,51 @@ +{ + "openai,openaicompat": { + "messages": [ + { + "tool_calls": [ + { + "id": "c1", + "function": { + "arguments": "{}", + "name": "view" + }, + "type": "function" + } + ], + "role": "assistant" + }, + { + "content": "cached result", + "tool_call_id": "c1", + "role": "tool" + } + ], + "warnings": [] + }, + "openrouter,vercel": { + "messages": [ + { + "tool_calls": [ + { + "id": "c1", + "function": { + "arguments": "{}", + "name": "view" + }, + "type": "function" + } + ], + "role": "assistant" + }, + { + "content": "cached result", + "tool_call_id": "c1", + "role": "tool", + "cache_control": { + "type": "ephemeral" + } + } + ], + "warnings": [] + } +} diff --git a/providers/internal/prompttest/testdata/tool_result_error.json b/providers/internal/prompttest/testdata/tool_result_error.json new file mode 100644 index 000000000..c4ee33167 --- /dev/null +++ b/providers/internal/prompttest/testdata/tool_result_error.json @@ -0,0 +1,25 @@ +{ + "openai,openaicompat,openrouter,vercel": { + "messages": [ + { + "tool_calls": [ + { + "id": "c1", + "function": { + "arguments": "{}", + "name": "view" + }, + "type": "function" + } + ], + "role": "assistant" + }, + { + "content": "tool blew up", + "tool_call_id": "c1", + "role": "tool" + } + ], + "warnings": [] + } +} diff --git a/providers/internal/prompttest/testdata/tool_result_media_audio_mpeg.json b/providers/internal/prompttest/testdata/tool_result_media_audio_mpeg.json new file mode 100644 index 000000000..67a8ffdf9 --- /dev/null +++ b/providers/internal/prompttest/testdata/tool_result_media_audio_mpeg.json @@ -0,0 +1,37 @@ +{ + "openai,openaicompat,openrouter,vercel": { + "messages": [ + { + "tool_calls": [ + { + "id": "c1", + "function": { + "arguments": "{}", + "name": "say" + }, + "type": "function" + } + ], + "role": "assistant" + }, + { + "content": "The tool returned audio/mpeg content; see the following user message.", + "tool_call_id": "c1", + "role": "tool" + }, + { + "content": [ + { + "input_audio": { + "data": "AQID", + "format": "mp3" + }, + "type": "input_audio" + } + ], + "role": "user" + } + ], + "warnings": [] + } +} diff --git a/providers/internal/prompttest/testdata/tool_result_media_audio_wav.json b/providers/internal/prompttest/testdata/tool_result_media_audio_wav.json new file mode 100644 index 000000000..0f1e412de --- /dev/null +++ b/providers/internal/prompttest/testdata/tool_result_media_audio_wav.json @@ -0,0 +1,37 @@ +{ + "openai,openaicompat,openrouter,vercel": { + "messages": [ + { + "tool_calls": [ + { + "id": "c1", + "function": { + "arguments": "{}", + "name": "say" + }, + "type": "function" + } + ], + "role": "assistant" + }, + { + "content": "The tool returned audio/wav content; see the following user message.", + "tool_call_id": "c1", + "role": "tool" + }, + { + "content": [ + { + "input_audio": { + "data": "AQID", + "format": "wav" + }, + "type": "input_audio" + } + ], + "role": "user" + } + ], + "warnings": [] + } +} diff --git a/providers/internal/prompttest/testdata/tool_result_media_image.json b/providers/internal/prompttest/testdata/tool_result_media_image.json new file mode 100644 index 000000000..14a5e5c56 --- /dev/null +++ b/providers/internal/prompttest/testdata/tool_result_media_image.json @@ -0,0 +1,36 @@ +{ + "openai,openaicompat,openrouter,vercel": { + "messages": [ + { + "tool_calls": [ + { + "id": "c1", + "function": { + "arguments": "{}", + "name": "view" + }, + "type": "function" + } + ], + "role": "assistant" + }, + { + "content": "The tool returned image/png content; see the following user message.", + "tool_call_id": "c1", + "role": "tool" + }, + { + "content": [ + { + "image_url": { + "url": "data:image/png;base64,iVBORw==" + }, + "type": "image_url" + } + ], + "role": "user" + } + ], + "warnings": [] + } +} diff --git a/providers/internal/prompttest/testdata/tool_result_media_image_with_text.json b/providers/internal/prompttest/testdata/tool_result_media_image_with_text.json new file mode 100644 index 000000000..bcd7cf743 --- /dev/null +++ b/providers/internal/prompttest/testdata/tool_result_media_image_with_text.json @@ -0,0 +1,36 @@ +{ + "openai,openaicompat,openrouter,vercel": { + "messages": [ + { + "tool_calls": [ + { + "id": "c1", + "function": { + "arguments": "{}", + "name": "view" + }, + "type": "function" + } + ], + "role": "assistant" + }, + { + "content": "Screenshot of the blockquote element.", + "tool_call_id": "c1", + "role": "tool" + }, + { + "content": [ + { + "image_url": { + "url": "data:image/png;base64,iVBORw==" + }, + "type": "image_url" + } + ], + "role": "user" + } + ], + "warnings": [] + } +} diff --git a/providers/internal/prompttest/testdata/tool_result_media_parallel_batch.json b/providers/internal/prompttest/testdata/tool_result_media_parallel_batch.json new file mode 100644 index 000000000..a22e53803 --- /dev/null +++ b/providers/internal/prompttest/testdata/tool_result_media_parallel_batch.json @@ -0,0 +1,53 @@ +{ + "openai,openaicompat,openrouter,vercel": { + "messages": [ + { + "tool_calls": [ + { + "id": "c1", + "function": { + "arguments": "{}", + "name": "view" + }, + "type": "function" + }, + { + "id": "c2", + "function": { + "arguments": "{}", + "name": "view" + }, + "type": "function" + } + ], + "role": "assistant" + }, + { + "content": "The tool returned image/png content; see the following user message.", + "tool_call_id": "c1", + "role": "tool" + }, + { + "content": "plain", + "tool_call_id": "c2", + "role": "tool" + }, + { + "content": [ + { + "image_url": { + "url": "data:image/png;base64,iVBORw==" + }, + "type": "image_url" + } + ], + "role": "user" + }, + { + "content": "next", + "role": "user" + } + ], + "warnings": [] + } +} diff --git a/providers/internal/prompttest/testdata/tool_result_media_unsupported.json b/providers/internal/prompttest/testdata/tool_result_media_unsupported.json new file mode 100644 index 000000000..030f10c93 --- /dev/null +++ b/providers/internal/prompttest/testdata/tool_result_media_unsupported.json @@ -0,0 +1,27 @@ +{ + "openai,openaicompat,openrouter,vercel": { + "messages": [ + { + "tool_calls": [ + { + "id": "c1", + "function": { + "arguments": "{}", + "name": "record" + }, + "type": "function" + } + ], + "role": "assistant" + }, + { + "content": "The tool returned video/mp4 content, which cannot be displayed.", + "tool_call_id": "c1", + "role": "tool" + } + ], + "warnings": [ + "other: tool result media type video/mp4 not supported, sending text placeholder only" + ] + } +} diff --git a/providers/internal/prompttest/testdata/user_audio_wav.json b/providers/internal/prompttest/testdata/user_audio_wav.json new file mode 100644 index 000000000..7e046eea4 --- /dev/null +++ b/providers/internal/prompttest/testdata/user_audio_wav.json @@ -0,0 +1,19 @@ +{ + "openai,openaicompat,openrouter,vercel": { + "messages": [ + { + "content": [ + { + "input_audio": { + "data": "AQID", + "format": "wav" + }, + "type": "input_audio" + } + ], + "role": "user" + } + ], + "warnings": [] + } +} diff --git a/providers/internal/prompttest/testdata/user_cache_control.json b/providers/internal/prompttest/testdata/user_cache_control.json new file mode 100644 index 000000000..98e2f8b13 --- /dev/null +++ b/providers/internal/prompttest/testdata/user_cache_control.json @@ -0,0 +1,23 @@ +{ + "openai,openaicompat": { + "messages": [ + { + "content": "cached user turn", + "role": "user" + } + ], + "warnings": [] + }, + "openrouter,vercel": { + "messages": [ + { + "content": "cached user turn", + "role": "user", + "cache_control": { + "type": "ephemeral" + } + } + ], + "warnings": [] + } +} diff --git a/providers/internal/prompttest/testdata/user_empty_is_dropped.json b/providers/internal/prompttest/testdata/user_empty_is_dropped.json new file mode 100644 index 000000000..19aa84a09 --- /dev/null +++ b/providers/internal/prompttest/testdata/user_empty_is_dropped.json @@ -0,0 +1,15 @@ +{ + "openai,openaicompat,openrouter,vercel": { + "messages": [ + { + "content": "", + "role": "user" + }, + { + "content": "real turn", + "role": "user" + } + ], + "warnings": [] + } +} diff --git a/providers/internal/prompttest/testdata/user_pdf.json b/providers/internal/prompttest/testdata/user_pdf.json new file mode 100644 index 000000000..a1b363793 --- /dev/null +++ b/providers/internal/prompttest/testdata/user_pdf.json @@ -0,0 +1,19 @@ +{ + "openai,openaicompat,openrouter,vercel": { + "messages": [ + { + "content": [ + { + "file": { + "file_data": "data:application/pdf;base64,JVBERi0xLjQ=", + "filename": "doc.pdf" + }, + "type": "file" + } + ], + "role": "user" + } + ], + "warnings": [] + } +} diff --git a/providers/internal/prompttest/testdata/user_text_and_image.json b/providers/internal/prompttest/testdata/user_text_and_image.json new file mode 100644 index 000000000..b73088fdb --- /dev/null +++ b/providers/internal/prompttest/testdata/user_text_and_image.json @@ -0,0 +1,22 @@ +{ + "openai,openaicompat,openrouter,vercel": { + "messages": [ + { + "content": [ + { + "text": "what is this", + "type": "text" + }, + { + "image_url": { + "url": "data:image/png;base64,iVBORw==" + }, + "type": "image_url" + } + ], + "role": "user" + } + ], + "warnings": [] + } +} diff --git a/providers/internal/prompttest/testdata/user_unsupported_media.json b/providers/internal/prompttest/testdata/user_unsupported_media.json new file mode 100644 index 000000000..241e1b253 --- /dev/null +++ b/providers/internal/prompttest/testdata/user_unsupported_media.json @@ -0,0 +1,9 @@ +{ + "openai,openaicompat,openrouter,vercel": { + "messages": null, + "warnings": [ + "other: file part media type video/mp4 not supported", + "other: dropping empty user message (contains neither user-facing content nor tool results)" + ] + } +} diff --git a/providers/kronk/error.go b/providers/kronk/error.go new file mode 100644 index 000000000..44ce256d8 --- /dev/null +++ b/providers/kronk/error.go @@ -0,0 +1,86 @@ +package kronk + +import ( + "context" + "errors" + "regexp" + "strconv" + "strings" + + "charm.land/fantasy" + krn "github.com/ardanlabs/kronk/sdk/kronk" + "github.com/ardanlabs/kronk/sdk/kronk/model" +) + +var kronkContextPattern = regexp.MustCompile(`input tokens \[(\d+)\] exceed context window \[(\d+)\]`) + +func toProviderErr(err error) error { + if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return err + } + + if errors.Is(err, krn.ErrAdmissionTimeout) { + return &fantasy.ProviderError{ + Title: "kronk model busy", + Message: err.Error(), + Cause: err, + TransientError: true, + } + } + + if isInvalidRequest(err) { + providerErr := &fantasy.ProviderError{ + Title: "invalid kronk request", + Message: err.Error(), + Cause: err, + } + parseContextTooLargeError(err.Error(), providerErr) + return providerErr + } + + if strings.Contains(err.Error(), "context window is full") { + return &fantasy.ProviderError{ + Title: "kronk context window exceeded", + Message: err.Error(), + Cause: err, + ContextTooLargeErr: true, + } + } + + if transportErr := fantasy.WrapTransportError(err); transportErr != err { + return transportErr + } + + return &fantasy.ProviderError{ + Title: "kronk request failed", + Message: err.Error(), + Cause: err, + } +} + +func toOperationErr(title string, err error) error { + mappedErr := toProviderErr(err) + if providerErr, ok := errors.AsType[*fantasy.ProviderError](mappedErr); ok && providerErr.Title == "kronk request failed" { + providerErr.Title = title + } + return mappedErr +} + +func isInvalidRequest(err error) bool { + return errors.Is(err, model.ErrInvalidRequest) || + errors.Is(err, model.ErrMessagesMissing) || + errors.Is(err, model.ErrMessagesInvalid) || + errors.Is(err, model.ErrFileInputsUnsupported) +} + +func parseContextTooLargeError(message string, providerErr *fantasy.ProviderError) { + matches := kronkContextPattern.FindStringSubmatch(message) + if matches == nil { + return + } + + providerErr.Title = "kronk context window exceeded" + providerErr.ContextTooLargeErr = true + providerErr.ContextUsedTokens, _ = strconv.Atoi(matches[1]) + providerErr.ContextMaxTokens, _ = strconv.Atoi(matches[2]) +} diff --git a/providers/kronk/error_test.go b/providers/kronk/error_test.go new file mode 100644 index 000000000..0611f2088 --- /dev/null +++ b/providers/kronk/error_test.go @@ -0,0 +1,86 @@ +package kronk + +import ( + "context" + "errors" + "fmt" + "io" + "testing" + + "charm.land/fantasy" + krn "github.com/ardanlabs/kronk/sdk/kronk" + "github.com/ardanlabs/kronk/sdk/kronk/model" +) + +func TestToProviderErr(t *testing.T) { + tests := []struct { + name string + err error + wantProviderErr bool + wantRetryable bool + wantContextLarge bool + wantUsedTokens int + wantMaxTokens int + }{ + {name: "nil"}, + {name: "canceled", err: context.Canceled}, + {name: "deadline", err: context.DeadlineExceeded}, + {name: "invalid", err: fmt.Errorf("chat: %w", model.ErrInvalidRequest), wantProviderErr: true}, + { + name: "context limit", + err: fmt.Errorf("chat: %w: input tokens [9000] exceed context window [8192]", model.ErrInvalidRequest), + wantProviderErr: true, + wantContextLarge: true, + wantUsedTokens: 9000, + wantMaxTokens: 8192, + }, + {name: "native context full", err: errors.New("unable to process request: the context window is full"), wantProviderErr: true, wantContextLarge: true}, + {name: "admission timeout", err: krn.ErrAdmissionTimeout, wantProviderErr: true, wantRetryable: true}, + {name: "transport", err: io.ErrUnexpectedEOF, wantProviderErr: true, wantRetryable: true}, + {name: "other", err: errors.New("model failed"), wantProviderErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := toProviderErr(tt.err) + providerErr, gotProviderErr := errors.AsType[*fantasy.ProviderError](got) + if gotProviderErr != tt.wantProviderErr { + t.Fatalf("ProviderError: got %t, want %t", gotProviderErr, tt.wantProviderErr) + } + if !tt.wantProviderErr { + if !errors.Is(got, tt.err) { + t.Errorf("error: got %v, want %v", got, tt.err) + } + return + } + if got := providerErr.IsRetryable(); got != tt.wantRetryable { + t.Errorf("IsRetryable: got %t, want %t", got, tt.wantRetryable) + } + if got := providerErr.IsContextTooLarge(); got != tt.wantContextLarge { + t.Errorf("IsContextTooLarge: got %t, want %t", got, tt.wantContextLarge) + } + if got := providerErr.ContextUsedTokens; got != tt.wantUsedTokens { + t.Errorf("ContextUsedTokens: got %d, want %d", got, tt.wantUsedTokens) + } + if got := providerErr.ContextMaxTokens; got != tt.wantMaxTokens { + t.Errorf("ContextMaxTokens: got %d, want %d", got, tt.wantMaxTokens) + } + }) + } +} + +func TestToOperationErr(t *testing.T) { + baseErr := errors.New("load failed") + err := toOperationErr("kronk model loading failed", baseErr) + + providerErr, ok := errors.AsType[*fantasy.ProviderError](err) + if !ok { + t.Fatalf("error: got %T, want *fantasy.ProviderError", err) + } + if got, want := providerErr.Title, "kronk model loading failed"; got != want { + t.Errorf("Title: got %q, want %q", got, want) + } + if !errors.Is(err, baseErr) { + t.Errorf("Cause: got %v, want %v", err, baseErr) + } +} diff --git a/providers/kronk/kronk.go b/providers/kronk/kronk.go index 6c698f7f1..61c081e13 100644 --- a/providers/kronk/kronk.go +++ b/providers/kronk/kronk.go @@ -25,8 +25,21 @@ type provider struct { kronks map[string]*kronk.Kronk } +// Provider is a Fantasy provider that owns local Kronk model resources. +type Provider interface { + fantasy.Provider + Close(context.Context) error +} + +var _ Provider = (*provider)(nil) + // New creates a new Kronk provider with the given options. func New(opts ...Option) (fantasy.Provider, error) { + return NewProvider(opts...) +} + +// NewProvider creates a Kronk provider with direct access to Close. +func NewProvider(opts ...Option) (Provider, error) { providerOptions := options{ languageModelOptions: make([]LanguageModelOption, 0), } @@ -65,12 +78,12 @@ func (p *provider) LanguageModel(ctx context.Context, modelURL string) (fantasy. mp, err := p.installSystem(ctx, modelURL) if err != nil { - return nil, fmt.Errorf("failed to install system: %w", err) + return nil, toOperationErr("kronk installation failed", err) } krn, err := p.newKronk(mp) if err != nil { - return nil, fmt.Errorf("failed to create kronk instance: %w", err) + return nil, toOperationErr("kronk model loading failed", err) } p.kronks[modelURL] = krn @@ -135,8 +148,7 @@ func (p *provider) newKronk(mp models.Path) (*kronk.Kronk, error) { return nil, fmt.Errorf("unable to init kronk: %w", err) } - cfg := p.options.modelConfig - cfg.ModelFiles = mp.ModelFiles + cfg := p.modelConfig(mp) krn, err := kronk.New(model.WithConfig(cfg)) if err != nil { @@ -145,3 +157,16 @@ func (p *provider) newKronk(mp models.Path) (*kronk.Kronk, error) { return krn, nil } + +func (p *provider) modelConfig(mp models.Path) model.Config { + cfg := model.NewConfig(p.options.modelOptions...) + cfg.ModelFiles = mp.ModelFiles + if cfg.ProjFile == "" { + cfg.ProjFile = mp.ProjFile + } + if cfg.MTPDrafterFile == "" { + cfg.MTPDrafterFile = mp.MTPFile + } + + return cfg +} diff --git a/providers/kronk/kronk_test.go b/providers/kronk/kronk_test.go new file mode 100644 index 000000000..37e62364f --- /dev/null +++ b/providers/kronk/kronk_test.go @@ -0,0 +1,57 @@ +package kronk + +import ( + "reflect" + "testing" + + "charm.land/fantasy" + "github.com/ardanlabs/kronk/sdk/kronk/model" + "github.com/ardanlabs/kronk/sdk/tools/models" +) + +var _ func(...Option) (fantasy.Provider, error) = New + +func TestModelConfigArtifacts(t *testing.T) { + tests := []struct { + name string + modelOptions []model.Option + wantProj string + wantMTP string + }{ + { + name: "downloaded companions", + wantProj: "downloaded-proj.gguf", + wantMTP: "downloaded-mtp.gguf", + }, + { + name: "configured companions", + modelOptions: []model.Option{ + model.WithProjFile("custom-proj.gguf"), + model.WithMTPDrafterFile("custom-mtp.gguf"), + }, + wantProj: "custom-proj.gguf", + wantMTP: "custom-mtp.gguf", + }, + } + mp := models.Path{ + ModelFiles: []string{"model.gguf"}, + ProjFile: "downloaded-proj.gguf", + MTPFile: "downloaded-mtp.gguf", + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := provider{options: options{modelOptions: tt.modelOptions}} + cfg := p.modelConfig(mp) + if got, want := cfg.ModelFiles, mp.ModelFiles; !reflect.DeepEqual(got, want) { + t.Errorf("ModelFiles: got %v, want %v", got, want) + } + if got := cfg.ProjFile; got != tt.wantProj { + t.Errorf("ProjFile: got %q, want %q", got, tt.wantProj) + } + if got := cfg.MTPDrafterFile; got != tt.wantMTP { + t.Errorf("MTPDrafterFile: got %q, want %q", got, tt.wantMTP) + } + }) + } +} diff --git a/providers/kronk/language_model.go b/providers/kronk/language_model.go index dba0adce8..4c8059502 100644 --- a/providers/kronk/language_model.go +++ b/providers/kronk/language_model.go @@ -1,14 +1,14 @@ package kronk import ( + "cmp" "context" "encoding/json" "errors" - "io" "charm.land/fantasy" "charm.land/fantasy/object" - "github.com/ardanlabs/kronk/sdk/kronk" + "charm.land/fantasy/providers/openai" "github.com/ardanlabs/kronk/sdk/kronk/model" xjson "github.com/charmbracelet/x/json" "github.com/google/uuid" @@ -17,13 +17,19 @@ import ( type languageModel struct { provider string modelID string - kronk *kronk.Kronk + kronk chatModel objectMode fantasy.ObjectMode prepareCallFunc LanguageModelPrepareCallFunc mapFinishReasonFunc LanguageModelMapFinishReasonFunc toPromptFunc LanguageModelToPromptFunc } +type chatModel interface { + Chat(context.Context, model.D) (model.ChatResponse, error) + ChatStreaming(context.Context, model.D) (<-chan model.ChatResponse, error) + ModelInfo() model.ModelInfo +} + // LanguageModelOption is a function that configures a languageModel. type LanguageModelOption func(*languageModel) @@ -55,7 +61,7 @@ func WithLanguageModelObjectMode(om fantasy.ObjectMode) LanguageModelOption { } } -func newLanguageModel(modelID string, provider string, krn *kronk.Kronk, opts ...LanguageModelOption) *languageModel { +func newLanguageModel(modelID string, provider string, krn chatModel, opts ...LanguageModelOption) *languageModel { lm := languageModel{ modelID: modelID, provider: provider, @@ -93,13 +99,6 @@ func (l *languageModel) Provider() string { func (l *languageModel) prepareDocument(call fantasy.Call) (model.D, []fantasy.CallWarning, error) { messages, warnings := l.toPromptFunc(call.Prompt, l.provider, l.modelID) - if call.TopK != nil { - warnings = append(warnings, fantasy.CallWarning{ - Type: fantasy.CallWarningTypeUnsupportedSetting, - Setting: "top_k", - }) - } - d := model.D{ "messages": messages, } @@ -116,20 +115,16 @@ func (l *languageModel) prepareDocument(call fantasy.Call) (model.D, []fantasy.C d["top_p"] = *call.TopP } + if call.TopK != nil { + d["top_k"] = *call.TopK + } + if call.FrequencyPenalty != nil { - warnings = append(warnings, fantasy.CallWarning{ - Type: fantasy.CallWarningTypeUnsupportedSetting, - Setting: "frequency_penalty", - Details: "frequency_penalty is not supported by Kronk", - }) + d["frequency_penalty"] = *call.FrequencyPenalty } if call.PresencePenalty != nil { - warnings = append(warnings, fantasy.CallWarning{ - Type: fantasy.CallWarningTypeUnsupportedSetting, - Setting: "presence_penalty", - Details: "presence_penalty is not supported by Kronk", - }) + d["presence_penalty"] = *call.PresencePenalty } optionsWarnings, err := l.prepareCallFunc(l, d, call) @@ -147,6 +142,21 @@ func (l *languageModel) prepareDocument(call fantasy.Call) (model.D, []fantasy.C warnings = append(warnings, toolWarnings...) } + if call.ToolChoice != nil { + switch *call.ToolChoice { + case fantasy.ToolChoiceNone, fantasy.ToolChoiceAuto, fantasy.ToolChoiceRequired: + d["tool_choice"] = string(*call.ToolChoice) + + default: + d["tool_choice"] = model.D{ + "type": "function", + "function": model.D{ + "name": string(*call.ToolChoice), + }, + } + } + } + return d, warnings, nil } @@ -157,51 +167,29 @@ func (l *languageModel) Generate(ctx context.Context, call fantasy.Call) (*fanta return nil, err } - ch, err := l.kronk.ChatStreaming(ctx, d) + response, err := l.kronk.Chat(ctx, d) if err != nil { return nil, toProviderErr(err) } - var lastResponse model.ChatResponse - var fullContent string - - for resp := range ch { - lastResponse = resp - - if len(resp.Choices) > 0 && resp.Choices[0].Delta != nil { - switch resp.Choices[0].FinishReason() { - case model.FinishReasonError: - return nil, &fantasy.Error{Title: "model error", Message: resp.Choices[0].Delta.Content} - - case model.FinishReasonStop, model.FinishReasonTool: - // Final response already contains full accumulated content in Delta.Content, - // so we use it directly instead of continuing to accumulate. - fullContent = resp.Choices[0].Delta.Content - - default: - fullContent += resp.Choices[0].Delta.Content - } - } - } - - if len(lastResponse.Choices) == 0 { + if len(response.Choices) == 0 { return nil, &fantasy.Error{Title: "no response", Message: "no response generated"} } - choice := lastResponse.Choices[0] + choice := response.Choices[0] var content []fantasy.Content - if choice.Delta != nil { - content = make([]fantasy.Content, 0, 1+len(choice.Delta.ToolCalls)) + if choice.Message != nil { + content = make([]fantasy.Content, 0, 1+len(choice.Message.ToolCalls)) } - if fullContent != "" { + if choice.Message != nil && choice.Message.Content != "" { content = append(content, fantasy.TextContent{ - Text: fullContent, + Text: choice.Message.Content, }) } - if choice.Delta != nil { - for _, tc := range choice.Delta.ToolCalls { + if choice.Message != nil { + for _, tc := range choice.Message.ToolCalls { // Marshal the underlying map directly, not the ToolCallArguments type // which has a custom MarshalJSON that double-encodes to a JSON string. argsJSON, _ := json.Marshal(map[string]any(tc.Function.Arguments)) @@ -216,35 +204,34 @@ func (l *languageModel) Generate(ctx context.Context, call fantasy.Call) (*fanta } usage := fantasy.Usage{} - if lastResponse.Usage != nil { + if response.Usage != nil { + outputTokens, totalTokens := openai.FoldDisjointReasoning( + int64(response.Usage.CompletionTokens), + int64(response.Usage.CompletionTokensDetails.ReasoningTokens), + int64(response.Usage.PromptTokens+response.Usage.CompletionTokens), + ) usage = fantasy.Usage{ - InputTokens: int64(lastResponse.Usage.PromptTokens), - OutputTokens: int64(lastResponse.Usage.CompletionTokens), - TotalTokens: int64(lastResponse.Usage.PromptTokens + lastResponse.Usage.CompletionTokens), - ReasoningTokens: int64(lastResponse.Usage.ReasoningTokens), + InputTokens: int64(response.Usage.PromptTokens), + OutputTokens: outputTokens, + TotalTokens: totalTokens, + ReasoningTokens: int64(response.Usage.CompletionTokensDetails.ReasoningTokens), + CacheReadTokens: int64(response.Usage.PromptTokensDetails.CachedTokens), } } mappedFinishReason := l.mapFinishReasonFunc(choice.FinishReason()) - if choice.Delta != nil && len(choice.Delta.ToolCalls) > 0 { + if choice.Message != nil && len(choice.Message.ToolCalls) > 0 { mappedFinishReason = fantasy.FinishReasonToolCalls } - providerMetadata := fantasy.ProviderMetadata{} - if lastResponse.Usage != nil { - providerMetadata = fantasy.ProviderMetadata{ - Name: &ProviderMetadata{ - TokensPerSecond: lastResponse.Usage.TokensPerSecond, - OutputTokens: int64(lastResponse.Usage.OutputTokens), - }, - } - } + metadata := newProviderMetadata(l.kronk.ModelInfo()) + metadata.update(response) resp := fantasy.Response{ Content: content, Usage: usage, FinishReason: mappedFinishReason, - ProviderMetadata: providerMetadata, + ProviderMetadata: fantasy.ProviderMetadata{Name: metadata}, Warnings: warnings, } @@ -257,6 +244,7 @@ func (l *languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy. if err != nil { return nil, err } + d["stream_options"] = model.D{"include_usage": true} ch, err := l.kronk.ChatStreaming(ctx, d) if err != nil { @@ -267,9 +255,8 @@ func (l *languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy. isActiveReasoning := false toolCalls := make(map[int]streamToolCall) - providerMetadata := fantasy.ProviderMetadata{ - Name: &ProviderMetadata{}, - } + metadata := newProviderMetadata(l.kronk.ModelInfo()) + providerMetadata := fantasy.ProviderMetadata{Name: metadata} var usage fantasy.Usage var finishReason string @@ -284,45 +271,54 @@ func (l *languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy. } } - toolIndex := 0 for resp := range ch { - if len(resp.Choices) == 0 { - continue - } - - choice := resp.Choices[0] - if choice.Delta == nil { - continue - } + metadata.update(resp) if resp.Usage != nil { + outputTokens, totalTokens := openai.FoldDisjointReasoning( + int64(resp.Usage.CompletionTokens), + int64(resp.Usage.CompletionTokensDetails.ReasoningTokens), + int64(resp.Usage.PromptTokens+resp.Usage.CompletionTokens), + ) usage = fantasy.Usage{ InputTokens: int64(resp.Usage.PromptTokens), - OutputTokens: int64(resp.Usage.CompletionTokens), - TotalTokens: int64(resp.Usage.PromptTokens + resp.Usage.CompletionTokens), - ReasoningTokens: int64(resp.Usage.ReasoningTokens), + OutputTokens: outputTokens, + TotalTokens: totalTokens, + ReasoningTokens: int64(resp.Usage.CompletionTokensDetails.ReasoningTokens), + CacheReadTokens: int64(resp.Usage.PromptTokensDetails.CachedTokens), } + } - if pm, ok := providerMetadata[Name]; ok { - if metadata, ok := pm.(*ProviderMetadata); ok { - metadata.TokensPerSecond = resp.Usage.TokensPerSecond - metadata.OutputTokens = int64(resp.Usage.OutputTokens) - } - } + if len(resp.Choices) == 0 { + continue } + choice := resp.Choices[0] if choice.FinishReason() != "" { finishReason = choice.FinishReason() } - switch choice.FinishReason() { - case model.FinishReasonError: + if choice.FinishReason() == model.FinishReasonError { + err := ctx.Err() + if err == nil { + message := "" + if choice.Delta != nil { + message = choice.Delta.Content + } + err = toProviderErr(errors.New(cmp.Or(message, "model stopped with an error"))) + } yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeError, - Error: &fantasy.Error{Title: "model error", Message: choice.Delta.Content}, + Error: err, }) return + } + if choice.Delta == nil { + continue + } + + switch choice.FinishReason() { case model.FinishReasonTool: if isActiveReasoning { isActiveReasoning = false @@ -344,56 +340,6 @@ func (l *languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy. } } - for _, tc := range choice.Delta.ToolCalls { - argsJSON, _ := json.Marshal(map[string]any(tc.Function.Arguments)) - argsStr := string(argsJSON) - - toolID := tc.ID - if toolID == "" { - toolID = uuid.NewString() - } - - if !yield(fantasy.StreamPart{ - Type: fantasy.StreamPartTypeToolInputStart, - ID: toolID, - ToolCallName: tc.Function.Name, - }) { - return - } - - if !yield(fantasy.StreamPart{ - Type: fantasy.StreamPartTypeToolInputDelta, - ID: toolID, - Delta: argsStr, - }) { - return - } - - if !yield(fantasy.StreamPart{ - Type: fantasy.StreamPartTypeToolInputEnd, - ID: toolID, - }) { - return - } - - if !yield(fantasy.StreamPart{ - Type: fantasy.StreamPartTypeToolCall, - ID: toolID, - ToolCallName: tc.Function.Name, - ToolCallInput: argsStr, - }) { - return - } - - toolCalls[toolIndex] = streamToolCall{ - id: toolID, - name: tc.Function.Name, - arguments: argsStr, - hasFinished: true, - } - toolIndex++ - } - default: if choice.Delta.Reasoning != "" { if !isActiveReasoning { @@ -415,7 +361,7 @@ func (l *languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy. } } - hasToolCalls := len(choice.Delta.ToolCalls) > 0 + hasToolCalls := len(choice.Delta.ToolCallDeltas) > 0 hasContent := choice.Delta.Content != "" if isActiveReasoning && (hasContent || hasToolCalls) { @@ -458,103 +404,65 @@ func (l *languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy. } } - for _, tc := range choice.Delta.ToolCalls { - argsJSON, _ := json.Marshal(map[string]any(tc.Function.Arguments)) - argsStr := string(argsJSON) - - switch existingTC, ok := toolCalls[toolIndex]; ok { - case true: - if existingTC.hasFinished { - continue + for _, tc := range choice.Delta.ToolCallDeltas { + toolCall, ok := toolCalls[tc.Index] + if !ok { + toolID := tc.ID + if toolID == "" { + toolID = uuid.NewString() } - existingTC.arguments += argsStr - + toolCall = streamToolCall{ + id: toolID, + name: tc.Function.Name, + } if !yield(fantasy.StreamPart{ - Type: fantasy.StreamPartTypeToolInputDelta, - ID: existingTC.id, - Delta: argsStr, + Type: fantasy.StreamPartTypeToolInputStart, + ID: toolCall.id, + ToolCallName: toolCall.name, }) { return } + } - toolCalls[toolIndex] = existingTC - - if xjson.IsValid(existingTC.arguments) { - if !yield(fantasy.StreamPart{ - Type: fantasy.StreamPartTypeToolInputEnd, - ID: existingTC.id, - }) { - return - } - - if !yield(fantasy.StreamPart{ - Type: fantasy.StreamPartTypeToolCall, - ID: existingTC.id, - ToolCallName: existingTC.name, - ToolCallInput: existingTC.arguments, - }) { - return - } - - existingTC.hasFinished = true - toolCalls[toolIndex] = existingTC - } - - case false: - toolID := tc.ID - if toolID == "" { - toolID = uuid.NewString() - } + if toolCall.hasFinished { + continue + } + if tc.Function.Name != "" { + toolCall.name = tc.Function.Name + } + if tc.Function.Arguments != "" { + toolCall.arguments += tc.Function.Arguments if !yield(fantasy.StreamPart{ - Type: fantasy.StreamPartTypeToolInputStart, - ID: toolID, - ToolCallName: tc.Function.Name, + Type: fantasy.StreamPartTypeToolInputDelta, + ID: toolCall.id, + Delta: tc.Function.Arguments, }) { return } + } - toolCalls[toolIndex] = streamToolCall{ - id: toolID, - name: tc.Function.Name, - arguments: argsStr, + if xjson.IsValid(toolCall.arguments) { + if !yield(fantasy.StreamPart{ + Type: fantasy.StreamPartTypeToolInputEnd, + ID: toolCall.id, + }) { + return } - if argsStr != "" && argsStr != "null" { - if !yield(fantasy.StreamPart{ - Type: fantasy.StreamPartTypeToolInputDelta, - ID: toolID, - Delta: argsStr, - }) { - return - } - - if xjson.IsValid(argsStr) { - if !yield(fantasy.StreamPart{ - Type: fantasy.StreamPartTypeToolInputEnd, - ID: toolID, - }) { - return - } - - if !yield(fantasy.StreamPart{ - Type: fantasy.StreamPartTypeToolCall, - ID: toolID, - ToolCallName: tc.Function.Name, - ToolCallInput: argsStr, - }) { - return - } - - stc := toolCalls[toolIndex] - stc.hasFinished = true - toolCalls[toolIndex] = stc - } + if !yield(fantasy.StreamPart{ + Type: fantasy.StreamPartTypeToolCall, + ID: toolCall.id, + ToolCallName: toolCall.name, + ToolCallInput: toolCall.arguments, + }) { + return } - - toolIndex++ + toolCall.hasFinished = true } + + toolCalls[tc.Index] = toolCall } } } @@ -577,6 +485,18 @@ func (l *languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy. } } + if finishReason == "" { + err := ctx.Err() + if err == nil { + err = fantasy.NewIncompleteStreamError() + } + yield(fantasy.StreamPart{ + Type: fantasy.StreamPartTypeError, + Error: err, + }) + return + } + mappedFinishReason := l.mapFinishReasonFunc(finishReason) if len(toolCalls) > 0 { mappedFinishReason = fantasy.FinishReasonToolCalls @@ -651,19 +571,3 @@ func toKronkTools(tools []fantasy.Tool) ([]model.D, []fantasy.CallWarning) { return kronkTools, warnings } - -func toProviderErr(err error) error { - if err == nil { - return nil - } - - if errors.Is(err, io.EOF) { - return nil - } - - return &fantasy.ProviderError{ - Title: "kronk error", - Message: err.Error(), - Cause: err, - } -} diff --git a/providers/kronk/language_model_hooks.go b/providers/kronk/language_model_hooks.go index a824f791d..65339a70f 100644 --- a/providers/kronk/language_model_hooks.go +++ b/providers/kronk/language_model_hooks.go @@ -33,6 +33,38 @@ func DefaultPrepareCallFunc(_ fantasy.LanguageModel, d model.D, call fantasy.Cal } } + if providerOptions.AdaptivePDecay != nil { + d["adaptive_p_decay"] = *providerOptions.AdaptivePDecay + } + + if providerOptions.AdaptivePTarget != nil { + d["adaptive_p_target"] = *providerOptions.AdaptivePTarget + } + + if providerOptions.DryAllowedLen != nil { + d["dry_allowed_length"] = *providerOptions.DryAllowedLen + } + + if providerOptions.DryBase != nil { + d["dry_base"] = *providerOptions.DryBase + } + + if providerOptions.DryMultiplier != nil { + d["dry_multiplier"] = *providerOptions.DryMultiplier + } + + if providerOptions.DryPenaltyLast != nil { + d["dry_penalty_last_n"] = *providerOptions.DryPenaltyLast + } + + if providerOptions.Grammar != nil { + d["grammar"] = *providerOptions.Grammar + } + + if providerOptions.Logprobs != nil { + d["logprobs"] = *providerOptions.Logprobs + } + if providerOptions.TopK != nil { d["top_k"] = *providerOptions.TopK } @@ -50,13 +82,41 @@ func DefaultPrepareCallFunc(_ fantasy.LanguageModel, d model.D, call fantasy.Cal } if providerOptions.NumPredict != nil { - d["num_predict"] = *providerOptions.NumPredict + d["max_tokens"] = *providerOptions.NumPredict + } + + if providerOptions.ReasoningEffort != nil { + d["reasoning_effort"] = *providerOptions.ReasoningEffort + } + + if providerOptions.RepeatLastN != nil { + d["repeat_last_n"] = *providerOptions.RepeatLastN } if providerOptions.Stop != nil { d["stop"] = providerOptions.Stop } + if providerOptions.Thinking != nil { + d["enable_thinking"] = *providerOptions.Thinking + } + + if providerOptions.TopLogprobs != nil { + d["top_logprobs"] = *providerOptions.TopLogprobs + } + + if providerOptions.XtcMinKeep != nil { + d["xtc_min_keep"] = *providerOptions.XtcMinKeep + } + + if providerOptions.XtcProbability != nil { + d["xtc_probability"] = *providerOptions.XtcProbability + } + + if providerOptions.XtcThreshold != nil { + d["xtc_threshold"] = *providerOptions.XtcThreshold + } + return warnings, nil } @@ -66,6 +126,9 @@ func DefaultMapFinishReasonFunc(finishReason string) fantasy.FinishReason { case string(model.FinishReasonStop): return fantasy.FinishReasonStop + case string(model.FinishReasonLength): + return fantasy.FinishReasonLength + case string(model.FinishReasonTool): return fantasy.FinishReasonToolCalls diff --git a/providers/kronk/language_model_hooks_test.go b/providers/kronk/language_model_hooks_test.go new file mode 100644 index 000000000..89d057157 --- /dev/null +++ b/providers/kronk/language_model_hooks_test.go @@ -0,0 +1,134 @@ +package kronk + +import ( + "reflect" + "testing" + + "charm.land/fantasy" + "github.com/ardanlabs/kronk/sdk/kronk/model" +) + +func TestPrepareDocumentSampling(t *testing.T) { + lm := languageModel{ + provider: Name, + prepareCallFunc: DefaultPrepareCallFunc, + toPromptFunc: DefaultToPrompt, + } + call := fantasy.Call{ + TopK: new(int64(20)), + FrequencyPenalty: new(0.2), + PresencePenalty: new(0.1), + } + + d, warnings, err := lm.prepareDocument(call) + if err != nil { + t.Fatalf("prepareDocument: %v", err) + } + if len(warnings) != 0 { + t.Fatalf("warnings: got %v, want none", warnings) + } + + tests := []struct { + name string + key string + want any + }{ + {name: "top-k", key: "top_k", want: int64(20)}, + {name: "frequency penalty", key: "frequency_penalty", want: 0.2}, + {name: "presence penalty", key: "presence_penalty", want: 0.1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := d[tt.key]; got != tt.want { + t.Errorf("%s: got %v, want %v", tt.key, got, tt.want) + } + }) + } +} + +func TestDefaultPrepareCallFuncSampling(t *testing.T) { + providerOptions := ProviderOptions{ + AdaptivePDecay: new(0.9), + AdaptivePTarget: new(0.1), + DryAllowedLen: new(int64(3)), + DryBase: new(1.75), + DryMultiplier: new(1.1), + DryPenaltyLast: new(int64(64)), + Grammar: new("root ::= \"yes\" | \"no\""), + Logprobs: new(true), + MinP: new(0.05), + NumPredict: new(int64(512)), + ReasoningEffort: new("high"), + RepeatLastN: new(int64(32)), + RepeatPenalty: new(1.15), + Seed: new(int64(42)), + Stop: []string{"STOP"}, + Thinking: new(false), + TopK: new(int64(20)), + TopLogprobs: new(int64(5)), + XtcMinKeep: new(int64(2)), + XtcProbability: new(0.5), + XtcThreshold: new(0.1), + } + call := fantasy.Call{ + ProviderOptions: NewProviderOptions(&providerOptions), + } + d := model.D{} + + warnings, err := DefaultPrepareCallFunc(nil, d, call) + if err != nil { + t.Fatalf("DefaultPrepareCallFunc: %v", err) + } + if len(warnings) != 0 { + t.Fatalf("warnings: got %v, want none", warnings) + } + + want := model.D{ + "adaptive_p_decay": 0.9, + "adaptive_p_target": 0.1, + "dry_allowed_length": int64(3), + "dry_base": 1.75, + "dry_multiplier": 1.1, + "dry_penalty_last_n": int64(64), + "enable_thinking": false, + "grammar": "root ::= \"yes\" | \"no\"", + "logprobs": true, + "max_tokens": int64(512), + "min_p": 0.05, + "reasoning_effort": "high", + "repeat_last_n": int64(32), + "repeat_penalty": 1.15, + "seed": int64(42), + "stop": []string{"STOP"}, + "top_k": int64(20), + "top_logprobs": int64(5), + "xtc_min_keep": int64(2), + "xtc_probability": 0.5, + "xtc_threshold": 0.1, + } + if !reflect.DeepEqual(d, want) { + t.Errorf("document: got %#v, want %#v", d, want) + } +} + +func TestDefaultMapFinishReasonFunc(t *testing.T) { + tests := []struct { + name string + finishReason string + want fantasy.FinishReason + }{ + {name: "stop", finishReason: model.FinishReasonStop, want: fantasy.FinishReasonStop}, + {name: "length", finishReason: model.FinishReasonLength, want: fantasy.FinishReasonLength}, + {name: "tool", finishReason: model.FinishReasonTool, want: fantasy.FinishReasonToolCalls}, + {name: "error", finishReason: model.FinishReasonError, want: fantasy.FinishReasonError}, + {name: "unknown", finishReason: "", want: fantasy.FinishReasonUnknown}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := DefaultMapFinishReasonFunc(tt.finishReason); got != tt.want { + t.Errorf("DefaultMapFinishReasonFunc: got %q, want %q", got, tt.want) + } + }) + } +} diff --git a/providers/kronk/language_model_test.go b/providers/kronk/language_model_test.go new file mode 100644 index 000000000..c86568579 --- /dev/null +++ b/providers/kronk/language_model_test.go @@ -0,0 +1,329 @@ +package kronk + +import ( + "context" + "errors" + "io" + "reflect" + "testing" + + "charm.land/fantasy" + "github.com/ardanlabs/kronk/sdk/kronk/model" +) + +type fakeChatModel struct { + chatResponse model.ChatResponse + chatErr error + streamResponse []model.ChatResponse + streamErr error + document model.D + modelInfo model.ModelInfo +} + +func (f *fakeChatModel) Chat(_ context.Context, d model.D) (model.ChatResponse, error) { + f.document = d + return f.chatResponse, f.chatErr +} + +func (f *fakeChatModel) ChatStreaming(_ context.Context, d model.D) (<-chan model.ChatResponse, error) { + f.document = d + if f.streamErr != nil { + return nil, f.streamErr + } + + ch := make(chan model.ChatResponse, len(f.streamResponse)) + for _, response := range f.streamResponse { + ch <- response + } + close(ch) + + return ch, nil +} + +func (f *fakeChatModel) ModelInfo() model.ModelInfo { + return f.modelInfo +} + +func TestPrepareDocumentToolChoice(t *testing.T) { + lm := languageModel{ + provider: Name, + prepareCallFunc: DefaultPrepareCallFunc, + toPromptFunc: DefaultToPrompt, + } + tool := fantasy.FunctionTool{Name: "weather", InputSchema: map[string]any{"type": "object"}} + + tests := []struct { + name string + toolChoice fantasy.ToolChoice + want any + }{ + {name: "none", toolChoice: fantasy.ToolChoiceNone, want: "none"}, + {name: "auto", toolChoice: fantasy.ToolChoiceAuto, want: "auto"}, + {name: "required", toolChoice: fantasy.ToolChoiceRequired, want: "required"}, + { + name: "specific", + toolChoice: fantasy.SpecificToolChoice("weather"), + want: model.D{ + "type": "function", + "function": model.D{ + "name": "weather", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d, _, err := lm.prepareDocument(fantasy.Call{ + Tools: []fantasy.Tool{tool}, + ToolChoice: &tt.toolChoice, + }) + if err != nil { + t.Fatalf("prepareDocument: %v", err) + } + if got := d["tool_choice"]; !reflect.DeepEqual(got, tt.want) { + t.Errorf("tool_choice: got %#v, want %#v", got, tt.want) + } + }) + } +} + +func TestGenerateResponse(t *testing.T) { + finishReason := model.FinishReasonTool + client := fakeChatModel{ + modelInfo: model.ModelInfo{ + ID: "loaded-model", + Desc: "test model", + Type: model.ModelTypeDense, + HasProjection: true, + Size: 1024, + VRAMTotal: 768, + SlotMemory: 256, + }, + chatResponse: model.ChatResponse{ + Model: "response-model", + SystemFingerprint: "fp_kronk", + Choices: []model.Choice{ + { + FinishReasonPtr: &finishReason, + Message: &model.ResponseMessage{ + Content: "calling weather", + ToolCalls: []model.ResponseToolCall{ + { + ID: "call-1", + Function: model.ResponseToolCallFunction{ + Name: "weather", + Arguments: model.ToolCallArguments{"city": "Paris"}, + }, + }, + }, + }, + }, + }, + Usage: &model.Usage{ + PromptTokens: 12, + PromptTokensDetails: model.PromptTokensDetails{CachedTokens: 3}, + CompletionTokens: 4, + TotalTokens: 16, + TokensPerSecond: 24.5, + TimeToFirstTokenMS: 32.5, + DraftTokens: 8, + DraftAcceptedTokens: 6, + DraftAcceptanceRate: 0.75, + DraftCoverage: 0.5, + DraftDisableReason: "imc-hit", + }, + }, + } + lm := newLanguageModel("model", Name, &client) + + response, err := lm.Generate(t.Context(), fantasy.Call{}) + if err != nil { + t.Fatalf("Generate: %v", err) + } + if got, want := response.Content.Text(), "calling weather"; got != want { + t.Errorf("Content.Text: got %q, want %q", got, want) + } + toolCalls := response.Content.ToolCalls() + if len(toolCalls) != 1 { + t.Fatalf("Content.ToolCalls: got %d, want 1", len(toolCalls)) + } + if got, want := toolCalls[0].Input, `{"city":"Paris"}`; got != want { + t.Errorf("ToolCall.Input: got %q, want %q", got, want) + } + if got, want := response.Usage.TotalTokens, int64(16); got != want { + t.Errorf("Usage.TotalTokens: got %d, want %d", got, want) + } + if got, want := response.Usage.CacheReadTokens, int64(3); got != want { + t.Errorf("Usage.CacheReadTokens: got %d, want %d", got, want) + } + if got, want := response.FinishReason, fantasy.FinishReasonToolCalls; got != want { + t.Errorf("FinishReason: got %q, want %q", got, want) + } + metadata, ok := response.ProviderMetadata[Name].(*ProviderMetadata) + if !ok { + t.Fatalf("ProviderMetadata: got %T, want *ProviderMetadata", response.ProviderMetadata[Name]) + } + if got, want := metadata.Model, "response-model"; got != want { + t.Errorf("ProviderMetadata.Model: got %q, want %q", got, want) + } + if got, want := metadata.SystemFingerprint, "fp_kronk"; got != want { + t.Errorf("ProviderMetadata.SystemFingerprint: got %q, want %q", got, want) + } + if !metadata.HasProjection { + t.Error("ProviderMetadata.HasProjection: got false, want true") + } + if got, want := metadata.TimeToFirstTokenMS, 32.5; got != want { + t.Errorf("ProviderMetadata.TimeToFirstTokenMS: got %v, want %v", got, want) + } + if got, want := metadata.DraftAcceptedTokens, int64(6); got != want { + t.Errorf("ProviderMetadata.DraftAcceptedTokens: got %d, want %d", got, want) + } +} + +func TestStreamResponse(t *testing.T) { + toolFinish := model.FinishReasonTool + client := fakeChatModel{ + modelInfo: model.ModelInfo{ID: "loaded-model", Type: model.ModelTypeDense}, + streamResponse: []model.ChatResponse{ + {Model: "response-model", SystemFingerprint: "fp_kronk", Choices: []model.Choice{{Delta: &model.ResponseMessage{Reasoning: "thinking"}}}}, + {Choices: []model.Choice{{Delta: &model.ResponseMessage{ToolCallDeltas: []model.ResponseToolCallDelta{ + {ID: "call-1", Index: 0, Function: model.ResponseToolCallDeltaFunction{Name: "weather"}}, + }}}}}, + {Choices: []model.Choice{{Delta: &model.ResponseMessage{ToolCallDeltas: []model.ResponseToolCallDelta{ + {Index: 0, Function: model.ResponseToolCallDeltaFunction{Arguments: `{"city":"Paris"}`}}, + }}}}}, + {Choices: []model.Choice{{Delta: &model.ResponseMessage{}, FinishReasonPtr: &toolFinish}}}, + {Choices: []model.Choice{}, Usage: &model.Usage{ + PromptTokens: 12, + CompletionTokens: 4, + TotalTokens: 16, + TokensPerSecond: 20, + TimeToFirstTokenMS: 30, + DraftTokens: 6, + DraftAcceptedTokens: 5, + }}, + }, + } + lm := newLanguageModel("model", Name, &client) + + stream, err := lm.Stream(t.Context(), fantasy.Call{}) + if err != nil { + t.Fatalf("Stream: %v", err) + } + + var parts []fantasy.StreamPart + for part := range stream { + parts = append(parts, part) + } + + wantTypes := []fantasy.StreamPartType{ + fantasy.StreamPartTypeReasoningStart, + fantasy.StreamPartTypeReasoningDelta, + fantasy.StreamPartTypeReasoningEnd, + fantasy.StreamPartTypeToolInputStart, + fantasy.StreamPartTypeToolInputDelta, + fantasy.StreamPartTypeToolInputEnd, + fantasy.StreamPartTypeToolCall, + fantasy.StreamPartTypeFinish, + } + gotTypes := make([]fantasy.StreamPartType, len(parts)) + for i := range parts { + gotTypes[i] = parts[i].Type + } + if !reflect.DeepEqual(gotTypes, wantTypes) { + t.Fatalf("part types: got %v, want %v", gotTypes, wantTypes) + } + if got, want := parts[6].ToolCallInput, `{"city":"Paris"}`; got != want { + t.Errorf("ToolCallInput: got %q, want %q", got, want) + } + if got, want := parts[7].Usage.TotalTokens, int64(16); got != want { + t.Errorf("Usage.TotalTokens: got %d, want %d", got, want) + } + if got, want := parts[7].FinishReason, fantasy.FinishReasonToolCalls; got != want { + t.Errorf("FinishReason: got %q, want %q", got, want) + } + metadata, ok := parts[7].ProviderMetadata[Name].(*ProviderMetadata) + if !ok { + t.Fatalf("ProviderMetadata: got %T, want *ProviderMetadata", parts[7].ProviderMetadata[Name]) + } + if got, want := metadata.Model, "response-model"; got != want { + t.Errorf("ProviderMetadata.Model: got %q, want %q", got, want) + } + if got, want := metadata.SystemFingerprint, "fp_kronk"; got != want { + t.Errorf("ProviderMetadata.SystemFingerprint: got %q, want %q", got, want) + } + if got, want := metadata.TimeToFirstTokenMS, float64(30); got != want { + t.Errorf("ProviderMetadata.TimeToFirstTokenMS: got %v, want %v", got, want) + } + if got, want := metadata.DraftAcceptedTokens, int64(5); got != want { + t.Errorf("ProviderMetadata.DraftAcceptedTokens: got %d, want %d", got, want) + } + if got := client.document["stream_options"]; !reflect.DeepEqual(got, model.D{"include_usage": true}) { + t.Errorf("stream_options: got %#v, want include_usage", got) + } +} + +func TestStreamIncomplete(t *testing.T) { + tests := []struct { + name string + ctx context.Context + wantErr error + }{ + {name: "incomplete", ctx: t.Context(), wantErr: io.ErrUnexpectedEOF}, + {name: "canceled", ctx: canceledContext(), wantErr: context.Canceled}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lm := newLanguageModel("model", Name, &fakeChatModel{}) + stream, err := lm.Stream(tt.ctx, fantasy.Call{}) + if err != nil { + t.Fatalf("Stream: %v", err) + } + + var parts []fantasy.StreamPart + for part := range stream { + parts = append(parts, part) + } + if len(parts) != 1 || parts[0].Type != fantasy.StreamPartTypeError { + t.Fatalf("parts: got %#v, want one error", parts) + } + if !errors.Is(parts[0].Error, tt.wantErr) { + t.Errorf("Error: got %v, want %v", parts[0].Error, tt.wantErr) + } + }) + } +} + +func TestStreamTerminalWithoutDelta(t *testing.T) { + finishReason := model.FinishReasonLength + client := fakeChatModel{ + streamResponse: []model.ChatResponse{ + {Choices: []model.Choice{{FinishReasonPtr: &finishReason}}}, + }, + } + lm := newLanguageModel("model", Name, &client) + + stream, err := lm.Stream(t.Context(), fantasy.Call{}) + if err != nil { + t.Fatalf("Stream: %v", err) + } + + var parts []fantasy.StreamPart + for part := range stream { + parts = append(parts, part) + } + if len(parts) != 1 || parts[0].Type != fantasy.StreamPartTypeFinish { + t.Fatalf("parts: got %#v, want one finish", parts) + } + if got, want := parts[0].FinishReason, fantasy.FinishReasonLength; got != want { + t.Errorf("FinishReason: got %q, want %q", got, want) + } +} + +func canceledContext() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + return ctx +} diff --git a/providers/kronk/options.go b/providers/kronk/options.go index 747920465..f8826ca04 100644 --- a/providers/kronk/options.go +++ b/providers/kronk/options.go @@ -3,6 +3,7 @@ package kronk import ( "context" "fmt" + "time" "charm.land/fantasy" "github.com/ardanlabs/kronk/sdk/kronk/model" @@ -16,7 +17,7 @@ type Logger func(ctx context.Context, msg string, args ...any) type options struct { name string - modelConfig model.Config + modelOptions []model.Option logger Logger objectMode fantasy.ObjectMode languageModelOptions []LanguageModelOption @@ -29,10 +30,257 @@ func WithName(name string) Option { } } -// WithModelConfig sets additional model configuration options. +// WithModelConfig replaces the model configuration at this point in the option sequence. +// The provider replaces ModelFiles with the files resolved by LanguageModel. func WithModelConfig(cfg model.Config) Option { + return withModelOption(model.WithConfig(cfg)) +} + +// WithConfig replaces the model configuration at this point in the option sequence. +// The provider replaces ModelFiles with the files resolved by LanguageModel. +func WithConfig(cfg model.Config) Option { + return withModelOption(model.WithConfig(cfg)) +} + +// WithAdapters configures model adapters. +func WithAdapters(v []model.AdapterConfig) Option { + return withModelOption(model.WithAdapters(v)) +} + +// WithAdmissionTimeout configures the model admission timeout. +func WithAdmissionTimeout(v time.Duration) Option { + return withModelOption(model.WithAdmissionTimeout(v)) +} + +// WithAutoTune enables hardware-aware model configuration. +func WithAutoTune(v bool) Option { + return withModelOption(model.WithAutoTune(v)) +} + +// WithCacheMinTokens configures the minimum number of tokens required for caching. +func WithCacheMinTokens(v int) Option { + return withModelOption(model.WithCacheMinTokens(v)) +} + +// WithCacheTypeK configures the key cache data type. +func WithCacheTypeK(v model.GGMLType) Option { + return withModelOption(model.WithCacheTypeK(v)) +} + +// WithCacheTypeV configures the value cache data type. +func WithCacheTypeV(v model.GGMLType) Option { + return withModelOption(model.WithCacheTypeV(v)) +} + +// WithContextWindow configures the model context window. +func WithContextWindow(v int) Option { + return withModelOption(model.WithContextWindow(v)) +} + +// WithDefaultParams configures the default model parameters. +func WithDefaultParams(v model.Params) Option { + return withModelOption(model.WithDefaultParams(v)) +} + +// WithChatTemplateKwargs configures the model chat template arguments. +func WithChatTemplateKwargs(v model.D) Option { + return withModelOption(model.WithChatTemplateKwargs(v)) +} + +// WithDevices configures the devices used for model execution. +func WithDevices(v []string) Option { + return withModelOption(model.WithDevices(v)) +} + +// WithDraftModel configures speculative decoding with a draft model. +func WithDraftModel(v *model.DraftModelConfig) Option { + return withModelOption(model.WithDraftModel(v)) +} + +// WithFlashAttention configures Flash Attention. +func WithFlashAttention(v model.FlashAttentionType) Option { + return withModelOption(model.WithFlashAttention(v)) +} + +// WithIMCSessionCapacity configures the incremental message cache session capacity. +func WithIMCSessionCapacity(v int) Option { + return withModelOption(model.WithIMCSessionCapacity(v)) +} + +// WithIncrementalCache configures incremental message caching. +func WithIncrementalCache(v bool) Option { + return withModelOption(model.WithIncrementalCache(v)) +} + +// WithInsecureLogging configures logging of potentially sensitive model data. +func WithInsecureLogging(v bool) Option { + return withModelOption(model.WithInsecureLogging(v)) +} + +// WithJinjaFile configures the model's Jinja template file. +func WithJinjaFile(v string) Option { + return withModelOption(model.WithJinjaFile(v)) +} + +// WithLoadMode configures how model weights are loaded. +func WithLoadMode(v model.LoadMode) Option { + return withModelOption(model.WithLoadMode(v)) +} + +// WithLog configures the logger used by model operations. +func WithLog(v model.Logger) Option { + return withModelOption(model.WithLog(v)) +} + +// WithMainGPU configures the primary GPU. +func WithMainGPU(v int) Option { + return withModelOption(model.WithMainGPU(v)) +} + +// WithMoE configures mixture-of-experts execution. +func WithMoE(v *model.MoEConfig) Option { + return withModelOption(model.WithMoE(v)) +} + +// WithModelFiles configures the model files at this point in the option sequence. +// The provider replaces them with the files resolved by LanguageModel. +func WithModelFiles(v []string) Option { + return withModelOption(model.WithModelFiles(v)) +} + +// WithNGpuLayers configures the number of model layers offloaded to the GPU. +func WithNGpuLayers(v int) Option { + return withModelOption(model.WithNGpuLayers(v)) +} + +// WithNSeqMax configures the maximum number of concurrent sequences. +func WithNSeqMax(v int) Option { + return withModelOption(model.WithNSeqMax(v)) +} + +// WithNThreads configures the number of generation threads. +func WithNThreads(v int) Option { + return withModelOption(model.WithNThreads(v)) +} + +// WithNThreadsBatch configures the number of batch-processing threads. +func WithNThreadsBatch(v int) Option { + return withModelOption(model.WithNThreadsBatch(v)) +} + +// WithNUMA configures the NUMA strategy. +func WithNUMA(v string) Option { + return withModelOption(model.WithNUMA(v)) +} + +// WithOffloadKQV configures KV cache offloading. +func WithOffloadKQV(v bool) Option { + return withModelOption(model.WithOffloadKQV(v)) +} + +// WithOpOffload configures host tensor operation offloading. +func WithOpOffload(v bool) Option { + return withModelOption(model.WithOpOffload(v)) +} + +// WithOpOffloadMinBatch configures the minimum batch size for operation offloading. +func WithOpOffloadMinBatch(v int) Option { + return withModelOption(model.WithOpOffloadMinBatch(v)) +} + +// WithPrefillBatchSize configures the maximum number of prompt tokens processed per decode iteration. +func WithPrefillBatchSize(v int) Option { + return withModelOption(model.WithPrefillBatchSize(v)) +} + +// WithProjFile configures the multimodal projection file. +func WithProjFile(v string) Option { + return withModelOption(model.WithProjFile(v)) +} + +// WithMTPDrafterFile configures the MTP drafter file. +func WithMTPDrafterFile(v string) Option { + return withModelOption(model.WithMTPDrafterFile(v)) +} + +// WithProjOnCPU configures projection execution on the CPU. +func WithProjOnCPU(v bool) Option { + return withModelOption(model.WithProjOnCPU(v)) +} + +// WithRopeFreqBase configures the RoPE base frequency. +func WithRopeFreqBase(v float32) Option { + return withModelOption(model.WithRopeFreqBase(v)) +} + +// WithRopeFreqScale configures the RoPE frequency scale. +func WithRopeFreqScale(v float32) Option { + return withModelOption(model.WithRopeFreqScale(v)) +} + +// WithRopeScaling configures the RoPE scaling strategy. +func WithRopeScaling(v model.RopeScalingType) Option { + return withModelOption(model.WithRopeScaling(v)) +} + +// WithSplitMode configures how the model is split across devices. +func WithSplitMode(v model.SplitMode) Option { + return withModelOption(model.WithSplitMode(v)) +} + +// WithSWAFull configures full-size sliding-window-attention caching. +func WithSWAFull(v bool) Option { + return withModelOption(model.WithSWAFull(v)) +} + +// WithTensorBuftOverrides configures tensor buffer type overrides. +func WithTensorBuftOverrides(v []string) Option { + return withModelOption(model.WithTensorBuftOverrides(v)) +} + +// WithTensorSplit configures model distribution across devices. +func WithTensorSplit(v []float32) Option { + return withModelOption(model.WithTensorSplit(v)) +} + +// WithYarnAttnFactor configures the YaRN attention factor. +func WithYarnAttnFactor(v float32) Option { + return withModelOption(model.WithYarnAttnFactor(v)) +} + +// WithYarnBetaFast configures the YaRN low correction dimension. +func WithYarnBetaFast(v float32) Option { + return withModelOption(model.WithYarnBetaFast(v)) +} + +// WithYarnBetaSlow configures the YaRN high correction dimension. +func WithYarnBetaSlow(v float32) Option { + return withModelOption(model.WithYarnBetaSlow(v)) +} + +// WithYarnExtFactor configures the YaRN extrapolation mix factor. +func WithYarnExtFactor(v float32) Option { + return withModelOption(model.WithYarnExtFactor(v)) +} + +// WithYarnOrigCtx configures the original YaRN training context size. +func WithYarnOrigCtx(v int) Option { + return withModelOption(model.WithYarnOrigCtx(v)) +} + +// WithQueueDepth configures the model admission queue depth. +func WithQueueDepth(v int) Option { + return withModelOption(model.WithQueueDepth(v)) +} + +// WithSessionStoreFactory configures the model session store factory. +func WithSessionStoreFactory(v model.SessionStoreFactory) Option { + return withModelOption(model.WithSessionStoreFactory(v)) +} + +func withModelOption(opt model.Option) Option { return func(o *options) { - o.modelConfig = cfg + o.modelOptions = append(o.modelOptions, opt) } } diff --git a/providers/kronk/provider_options.go b/providers/kronk/provider_options.go index 217242ea4..74626a8b1 100644 --- a/providers/kronk/provider_options.go +++ b/providers/kronk/provider_options.go @@ -4,6 +4,7 @@ import ( "encoding/json" "charm.land/fantasy" + "github.com/ardanlabs/kronk/sdk/kronk/model" ) // Global type identifiers for Kronk-specific provider data. @@ -33,8 +34,57 @@ func init() { // ProviderMetadata represents additional metadata from Kronk provider. type ProviderMetadata struct { - TokensPerSecond float64 `json:"tokens_per_second"` - OutputTokens int64 `json:"output_tokens"` + Model string `json:"model"` + ModelDescription string `json:"model_description,omitempty"` + ModelType string `json:"model_type,omitempty"` + SystemFingerprint string `json:"system_fingerprint,omitempty"` + HasProjection bool `json:"has_projection"` + ModelSizeBytes uint64 `json:"model_size_bytes,omitempty"` + VRAMBytes int64 `json:"vram_bytes,omitempty"` + SlotMemoryBytes int64 `json:"slot_memory_bytes,omitempty"` + CachedInputTokens int64 `json:"cached_input_tokens,omitempty"` + OutputTokens int64 `json:"output_tokens"` + TokensPerSecond float64 `json:"tokens_per_second"` + TimeToFirstTokenMS float64 `json:"time_to_first_token_ms"` + DraftTokens int64 `json:"draft_tokens,omitempty"` + DraftAcceptedTokens int64 `json:"draft_accepted_tokens,omitempty"` + DraftAcceptanceRate float64 `json:"draft_acceptance_rate,omitempty"` + DraftCoverage float64 `json:"draft_coverage,omitempty"` + DraftDisableReason string `json:"draft_disable_reason,omitempty"` +} + +func newProviderMetadata(info model.ModelInfo) *ProviderMetadata { + return &ProviderMetadata{ + Model: info.ID, + ModelDescription: info.Desc, + ModelType: info.Type.String(), + HasProjection: info.HasProjection, + ModelSizeBytes: info.Size, + VRAMBytes: info.VRAMTotal, + SlotMemoryBytes: info.SlotMemory, + } +} + +func (m *ProviderMetadata) update(response model.ChatResponse) { + if response.Model != "" { + m.Model = response.Model + } + if response.SystemFingerprint != "" { + m.SystemFingerprint = response.SystemFingerprint + } + if response.Usage == nil { + return + } + + m.CachedInputTokens = int64(response.Usage.PromptTokensDetails.CachedTokens) + m.OutputTokens = int64(response.Usage.CompletionTokens) + m.TokensPerSecond = response.Usage.TokensPerSecond + m.TimeToFirstTokenMS = response.Usage.TimeToFirstTokenMS + m.DraftTokens = int64(response.Usage.DraftTokens) + m.DraftAcceptedTokens = int64(response.Usage.DraftAcceptedTokens) + m.DraftAcceptanceRate = response.Usage.DraftAcceptanceRate + m.DraftCoverage = response.Usage.DraftCoverage + m.DraftDisableReason = response.Usage.DraftDisableReason } // Options implements the ProviderOptionsData interface. @@ -59,12 +109,27 @@ func (m *ProviderMetadata) UnmarshalJSON(data []byte) error { // ProviderOptions represents additional options for Kronk provider. type ProviderOptions struct { - TopK *int64 `json:"top_k"` - RepeatPenalty *float64 `json:"repeat_penalty"` - Seed *int64 `json:"seed"` - MinP *float64 `json:"min_p"` - NumPredict *int64 `json:"num_predict"` - Stop []string `json:"stop"` + AdaptivePDecay *float64 `json:"adaptive_p_decay"` + AdaptivePTarget *float64 `json:"adaptive_p_target"` + DryAllowedLen *int64 `json:"dry_allowed_length"` + DryBase *float64 `json:"dry_base"` + DryMultiplier *float64 `json:"dry_multiplier"` + DryPenaltyLast *int64 `json:"dry_penalty_last_n"` + Grammar *string `json:"grammar"` + Logprobs *bool `json:"logprobs"` + MinP *float64 `json:"min_p"` + NumPredict *int64 `json:"num_predict"` + ReasoningEffort *string `json:"reasoning_effort"` + RepeatLastN *int64 `json:"repeat_last_n"` + RepeatPenalty *float64 `json:"repeat_penalty"` + Seed *int64 `json:"seed"` + Stop []string `json:"stop"` + Thinking *bool `json:"enable_thinking"` + TopK *int64 `json:"top_k"` + TopLogprobs *int64 `json:"top_logprobs"` + XtcMinKeep *int64 `json:"xtc_min_keep"` + XtcProbability *float64 `json:"xtc_probability"` + XtcThreshold *float64 `json:"xtc_threshold"` } // Options implements the ProviderOptionsData interface. diff --git a/providers/openai/error.go b/providers/openai/error.go index 7b688c942..ff028f3b9 100644 --- a/providers/openai/error.go +++ b/providers/openai/error.go @@ -16,14 +16,17 @@ import ( ) var ( - openaiContextPattern = regexp.MustCompile(`maximum context length (?:is|of) (\d+) tokens.*?(?:resulted in|requested) ~?(\d+) tokens`) - alibabaContextPattern = regexp.MustCompile(`Range of input length should be \[\d+,\s*(\d+)\]`) - vercelContextPattern = regexp.MustCompile(`Input too long:\s*(\d+)\s*input tokens,\s*limit is\s*(\d+)`) + openaiContextPattern = regexp.MustCompile(`maximum context length (?:is|of) (\d+) tokens.*?(?:resulted in|requested) ~?(\d+) tokens`) + alibabaContextPattern = regexp.MustCompile(`Range of input length should be \[\d+,\s*(\d+)\]`) + basetenContextPattern = regexp.MustCompile(`Input length (\d+) exceeds the maximum allowed input length of (\d+) tokens`) + fireworksContextPattern = regexp.MustCompile(`The prompt is too long:\s*(\d+),\s*model maximum context length:\s*(\d+)`) + parasailOneContextPattern = regexp.MustCompile(`maximum context length is (\d+) tokens, but the prompt contains (\d+) input tokens`) + parasailTwoContextPattern = regexp.MustCompile(`maximum context length is (\d+) tokens\. However, you requested \d+ output tokens and your prompt contains at least (\d+) input tokens`) + vercelContextPattern = regexp.MustCompile(`Input too long:\s*(\d+)\s*input tokens,\s*limit is\s*(\d+)`) ) func toProviderErr(err error) error { - var apiErr *openai.Error - if errors.As(err, &apiErr) { + if apiErr, ok := errors.AsType[*openai.Error](err); ok { message := toProviderErrMessage(apiErr) providerErr := &fantasy.ProviderError{ Title: cmp.Or(fantasy.ErrorTitleForStatusCode(apiErr.StatusCode), "provider request failed"), @@ -34,6 +37,7 @@ func toProviderErr(err error) error { RequestBody: apiErr.DumpRequest(true), ResponseHeaders: toHeaderMap(apiErr.Response.Header), ResponseBody: apiErr.DumpResponse(true), + ErrorType: cmp.Or(apiErr.Type, apiErr.Code), } parseContextTooLargeError(message, providerErr) @@ -43,8 +47,7 @@ func toProviderErr(err error) error { // Mid-stream SSE error events surface as *ssestream.StreamError, not // *openai.Error, so they need their own classification path. - var streamErr *ssestream.StreamError - if errors.As(err, &streamErr) { + if streamErr, ok := errors.AsType[*ssestream.StreamError](err); ok { return toProviderErrFromStreamError(streamErr) } @@ -74,6 +77,7 @@ func toProviderErrFromStreamError(streamErr *ssestream.StreamError) *fantasy.Pro Message: cmp.Or(envelope.Error.Message, streamErr.Message), Cause: streamErr, ResponseBody: streamErr.Event.Data, + ErrorType: errType, TransientError: fantasy.TransientStreamErrorTypes[errType], } } @@ -90,6 +94,30 @@ func parseContextTooLargeError(message string, providerErr *fantasy.ProviderErro providerErr.ContextMaxTokens, _ = strconv.Atoi(matches[1]) return } + if matches := basetenContextPattern.FindStringSubmatch(message); matches != nil { + providerErr.ContextTooLargeErr = true + providerErr.ContextUsedTokens, _ = strconv.Atoi(matches[1]) + providerErr.ContextMaxTokens, _ = strconv.Atoi(matches[2]) + return + } + if matches := fireworksContextPattern.FindStringSubmatch(message); matches != nil { + providerErr.ContextTooLargeErr = true + providerErr.ContextUsedTokens, _ = strconv.Atoi(matches[1]) + providerErr.ContextMaxTokens, _ = strconv.Atoi(matches[2]) + return + } + if matches := parasailOneContextPattern.FindStringSubmatch(message); matches != nil { + providerErr.ContextTooLargeErr = true + providerErr.ContextMaxTokens, _ = strconv.Atoi(matches[1]) + providerErr.ContextUsedTokens, _ = strconv.Atoi(matches[2]) + return + } + if matches := parasailTwoContextPattern.FindStringSubmatch(message); matches != nil { + providerErr.ContextTooLargeErr = true + providerErr.ContextMaxTokens, _ = strconv.Atoi(matches[1]) + providerErr.ContextUsedTokens, _ = strconv.Atoi(matches[2]) + return + } if matches := vercelContextPattern.FindStringSubmatch(message); matches != nil { providerErr.ContextTooLargeErr = true providerErr.ContextUsedTokens, _ = strconv.Atoi(matches[1]) diff --git a/providers/openai/error_test.go b/providers/openai/error_test.go index 01a8e31f6..1d9e3a0a9 100644 --- a/providers/openai/error_test.go +++ b/providers/openai/error_test.go @@ -1,9 +1,12 @@ package openai import ( + "context" "errors" "fmt" "io" + "net/http" + "net/http/httptest" "testing" "charm.land/fantasy" @@ -29,8 +32,8 @@ func TestToProviderErr_WrapsUnexpectedEOF(t *testing.T) { got := toProviderErr(tc.err) - var providerErr *fantasy.ProviderError - if !errors.As(got, &providerErr) { + providerErr, ok := errors.AsType[*fantasy.ProviderError](got) + if !ok { t.Fatalf("toProviderErr did not wrap %v as *fantasy.ProviderError (got %T)", tc.err, got) } if !errors.Is(providerErr.Cause, io.ErrUnexpectedEOF) { @@ -57,8 +60,7 @@ func TestToProviderErr_PassesThroughPlainEOF(t *testing.T) { t.Parallel() got := toProviderErr(io.EOF) - var providerErr *fantasy.ProviderError - if errors.As(got, &providerErr) { + if _, ok := errors.AsType[*fantasy.ProviderError](got); ok { t.Errorf("toProviderErr wrapped io.EOF as ProviderError; should pass through") } } @@ -77,8 +79,8 @@ func TestToProviderErr_StreamErrorServerErrorIsRetryable(t *testing.T) { got := toProviderErr(streamErr) - var providerErr *fantasy.ProviderError - if !errors.As(got, &providerErr) { + providerErr, ok := errors.AsType[*fantasy.ProviderError](got) + if !ok { t.Fatalf("toProviderErr did not wrap StreamError as *fantasy.ProviderError (got %T)", got) } if !providerErr.IsRetryable() { @@ -114,8 +116,8 @@ func TestToProviderErr_StreamErrorTransientTypes(t *testing.T) { got := toProviderErr(streamErr) - var providerErr *fantasy.ProviderError - if !errors.As(got, &providerErr) { + providerErr, ok := errors.AsType[*fantasy.ProviderError](got) + if !ok { t.Fatalf("toProviderErr did not wrap StreamError (got %T)", got) } if !providerErr.IsRetryable() { @@ -137,8 +139,8 @@ func TestToProviderErr_StreamErrorUnknownTypeIsNotRetryable(t *testing.T) { got := toProviderErr(streamErr) - var providerErr *fantasy.ProviderError - if !errors.As(got, &providerErr) { + providerErr, ok := errors.AsType[*fantasy.ProviderError](got) + if !ok { t.Fatalf("toProviderErr did not wrap StreamError as *fantasy.ProviderError (got %T)", got) } if providerErr.IsRetryable() { @@ -156,8 +158,8 @@ func TestToProviderErr_StreamErrorMalformedBodyFallsBackToRawMessage(t *testing. got := toProviderErr(streamErr) - var providerErr *fantasy.ProviderError - if !errors.As(got, &providerErr) { + providerErr, ok := errors.AsType[*fantasy.ProviderError](got) + if !ok { t.Fatalf("toProviderErr did not wrap StreamError as *fantasy.ProviderError (got %T)", got) } if providerErr.Message != streamErr.Message { @@ -167,3 +169,85 @@ func TestToProviderErr_StreamErrorMalformedBodyFallsBackToRawMessage(t *testing. t.Error("unparseable stream error body must not be assumed retryable") } } + +// ErrorType carries the provider's own name for the failure on both paths an +// OpenAI error can take, so a caller does not have to tell them apart. +func TestToProviderErr_CarriesTheErrorType(t *testing.T) { + t.Parallel() + + t.Run("stream error event", func(t *testing.T) { + t.Parallel() + + streamErr := &ssestream.StreamError{ + Event: ssestream.Event{ + Type: "error", + Data: []byte(`{"error":{"message":"Overloaded","type":"server_error"}}`), + }, + } + + providerErr, ok := errors.AsType[*fantasy.ProviderError](toProviderErr(streamErr)) + if !ok { + t.Fatalf("toProviderErr did not wrap %v as *fantasy.ProviderError", streamErr) + } + if providerErr.ErrorType != "server_error" { + t.Errorf("ErrorType = %q, want %q", providerErr.ErrorType, "server_error") + } + }) + + // code stands in when the payload names no type: OpenAI puts the useful + // word in whichever it feels like, e.g. type "tokens" alongside code + // "rate_limit_exceeded". + t.Run("code stands in for a missing type", func(t *testing.T) { + t.Parallel() + + streamErr := &ssestream.StreamError{ + Event: ssestream.Event{ + Type: "error", + Data: []byte(`{"error":{"message":"Rate limited","code":"rate_limit_exceeded"}}`), + }, + } + + providerErr, ok := errors.AsType[*fantasy.ProviderError](toProviderErr(streamErr)) + if !ok { + t.Fatalf("toProviderErr did not wrap %v as *fantasy.ProviderError", streamErr) + } + if providerErr.ErrorType != "rate_limit_exceeded" { + t.Errorf("ErrorType = %q, want %q", providerErr.ErrorType, "rate_limit_exceeded") + } + }) + + // The other path an OpenAI error takes: a rejected request rather than a + // failing stream. A real round trip is what builds the SDK error here, + // since it reads the type off fields only the transport fills in. + t.Run("rejected request", func(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(`{"error":{"message":"Rate limit reached","type":"tokens","code":"rate_limit_exceeded"}}`)) + })) + defer server.Close() + + provider, err := New(WithAPIKey("test-api-key"), WithBaseURL(server.URL)) + if err != nil { + t.Fatalf("New: %v", err) + } + model, err := provider.LanguageModel(context.Background(), "gpt-4") + if err != nil { + t.Fatalf("LanguageModel: %v", err) + } + _, err = model.Generate(context.Background(), fantasy.Call{Prompt: testPrompt}) + if err == nil { + t.Fatal("the request was meant to be rejected") + } + + providerErr, ok := errors.AsType[*fantasy.ProviderError](err) + if !ok { + t.Fatalf("error is %T, want *fantasy.ProviderError", err) + } + if providerErr.ErrorType != "tokens" { + t.Errorf("ErrorType = %q, want %q", providerErr.ErrorType, "tokens") + } + }) +} diff --git a/providers/openai/language_model.go b/providers/openai/language_model.go index bf5e43060..c7b3d3601 100644 --- a/providers/openai/language_model.go +++ b/providers/openai/language_model.go @@ -7,6 +7,8 @@ import ( "errors" "fmt" "io" + "maps" + "net/http" "reflect" "slices" "strings" @@ -16,6 +18,7 @@ import ( "charm.land/fantasy/schema" "github.com/google/uuid" "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/option" "github.com/openai/openai-go/v3/packages/param" "github.com/openai/openai-go/v3/shared" ) @@ -33,6 +36,7 @@ type languageModel struct { streamUsageFunc LanguageModelStreamUsageFunc streamExtraFunc LanguageModelStreamExtraFunc streamProviderMetadataFunc LanguageModelStreamProviderMetadataFunc + headerFunc LanguageModelHeaderFunc toPromptFunc LanguageModelToPromptFunc } @@ -81,6 +85,17 @@ func WithLanguageModelStreamUsageFunc(fn LanguageModelStreamUsageFunc) LanguageM } } +// WithLanguageModelHeaderFunc sets the response header function for the +// language model. When set, the HTTP response headers of each call are +// captured and passed to the function alongside the provider metadata, +// which it may mutate (e.g. copying headers of interest into ExtraFields). +// When unset, response headers are not captured at all. +func WithLanguageModelHeaderFunc(fn LanguageModelHeaderFunc) LanguageModelOption { + return func(l *languageModel) { + l.headerFunc = fn + } +} + // WithLanguageModelToPromptFunc sets the to prompt function for the language model. func WithLanguageModelToPromptFunc(fn LanguageModelToPromptFunc) LanguageModelOption { return func(l *languageModel) { @@ -133,6 +148,112 @@ type streamToolCall struct { hasFinished bool } +// responseCapture holds the raw HTTP response of a call so response headers +// can be surfaced through provider metadata. Capturing is only enabled when +// a header func is configured on the language model. +type responseCapture struct { + response *http.Response +} + +// requestOptions returns the given per-call request options with the raw +// HTTP response capture appended when the given header func is configured. +func (c *responseCapture) requestOptions(headerFunc LanguageModelHeaderFunc, opts []option.RequestOption) []option.RequestOption { + if headerFunc == nil { + return opts + } + return append(opts, + option.WithResponseInto(&c.response), + option.WithMiddleware(drainOnCloseMiddleware), + ) +} + +// drainOnCloseMiddleware wraps the response body so that a close before +// EOF — which the SSE stream does at its [DONE] sentinel — drains the +// remaining bytes first. net/http parses HTTP trailers only once the body +// has been read that far, so without the drain a trailer arriving after +// the stream's terminal event would be discarded. +func drainOnCloseMiddleware(req *http.Request, next option.MiddlewareNext) (*http.Response, error) { + res, err := next(req) + if err != nil || res == nil || res.Body == nil { + return res, err + } + res.Body = &drainOnCloseBody{ReadCloser: res.Body} + return res, err +} + +// drainOnCloseBody reads the wrapped body to EOF before closing it. +type drainOnCloseBody struct { + io.ReadCloser +} + +func (b *drainOnCloseBody) Close() error { + _, _ = io.Copy(io.Discard, b.ReadCloser) + return b.ReadCloser.Close() +} + +// header returns the captured response headers, with any HTTP trailers +// merged in (trailer keys win on collision), or nil when no response was +// captured. The header func runs after the response body has been fully +// consumed, so trailers — which net/http populates only then — are visible. +func (c *responseCapture) header() http.Header { + if c.response == nil { + return nil + } + if len(c.response.Trailer) == 0 { + return c.response.Header + } + merged := c.response.Header.Clone() + maps.Copy(merged, c.response.Trailer) + return merged +} + +// languageModelHeaderFunc returns the header func configured through the +// given language model options, if any. It is the only language model +// option also honored by the responses language model. +func languageModelHeaderFunc(opts []LanguageModelOption) LanguageModelHeaderFunc { + var lm languageModel + for _, opt := range opts { + opt(&lm) + } + return lm.headerFunc +} + +// applyHeaders invokes the configured header func against the non-stream +// provider metadata. It is a no-op when no header func is configured or no +// response was captured. +func (o languageModel) applyHeaders(header http.Header, providerMetadata fantasy.ProviderOptionsData) fantasy.ProviderOptionsData { + if o.headerFunc == nil || header == nil { + return providerMetadata + } + metadata, ok := providerMetadata.(*ProviderMetadata) + if !ok { + metadata = &ProviderMetadata{} + providerMetadata = metadata + } + o.headerFunc(header, metadata) + return providerMetadata +} + +// applyHeadersStream invokes the configured header func against the stream +// provider metadata, creating the metadata when the stream carried none. +// It is a no-op when no header func is configured or no response was +// captured. It must run once, after the stream loop, because +// streamUsageFunc replaces the metadata on every chunk. +func (o languageModel) applyHeadersStream(header http.Header, providerMetadata *fantasy.ProviderMetadata) { + if o.headerFunc == nil || header == nil { + return + } + if *providerMetadata == nil { + *providerMetadata = fantasy.ProviderMetadata{} + } + metadata, ok := (*providerMetadata)[Name].(*ProviderMetadata) + if !ok { + metadata = &ProviderMetadata{} + (*providerMetadata)[Name] = metadata + } + o.headerFunc(header, metadata) +} + // Model implements fantasy.LanguageModel. func (o languageModel) Model() string { return o.modelID @@ -251,11 +372,12 @@ func (o languageModel) prepareParams(call fantasy.Call) (*openai.ChatCompletionN // Generate implements fantasy.LanguageModel. func (o languageModel) Generate(ctx context.Context, call fantasy.Call) (*fantasy.Response, error) { + capture := responseCapture{} params, warnings, err := o.prepareParams(call) if err != nil { return nil, err } - response, err := o.client.Chat.Completions.New(ctx, *params, append(callUARequestOptions(call), callHeadersRequestOptions(call)...)...) + response, err := o.client.Chat.Completions.New(ctx, *params, capture.requestOptions(o.headerFunc, append(callUARequestOptions(call), callHeadersRequestOptions(call)...))...) if err != nil { return nil, toProviderErr(err) } @@ -278,15 +400,42 @@ func (o languageModel) Generate(ctx context.Context, call fantasy.Call) (*fantas extraContent := o.extraContentFunc(choice) content = append(content, extraContent...) } - for _, tc := range choice.Message.ToolCalls { - toolCallID := tc.ID - content = append(content, fantasy.ToolCallContent{ - ProviderExecuted: false, - ToolCallID: toolCallID, - ToolName: tc.Function.Name, - Input: tc.Function.Arguments, + + usage, providerMetadata := o.usageFunc(*response) + providerMetadata = o.applyHeaders(capture.header(), providerMetadata) + + mappedFinishReason := o.mapFinishReasonFunc(choice.FinishReason) + // Terminal reasons that can cut output mid-call — length, + // content_filter, provider errors — must not be rewritten into a + // tool-call turn: dispatching their partial calls executes truncated + // input (CHARM-2020). + suppressedToolCalls := len(choice.Message.ToolCalls) > 0 && + (mappedFinishReason == fantasy.FinishReasonLength || + mappedFinishReason == fantasy.FinishReasonContentFilter || + mappedFinishReason == fantasy.FinishReasonError) + if len(choice.Message.ToolCalls) > 0 && !suppressedToolCalls { + mappedFinishReason = fantasy.FinishReasonToolCalls + } + if suppressedToolCalls { + warnings = append(warnings, fantasy.CallWarning{ + Type: fantasy.CallWarningTypeOther, + Message: "tool calls were returned but the turn ended abnormally (token limit, content filter, or provider error); arguments may be truncated", }) } + + // Suppress truncated tool-call content so agents don't dispatch + // calls with incomplete arguments. + if !suppressedToolCalls { + for _, tc := range choice.Message.ToolCalls { + toolCallID := tc.ID + content = append(content, fantasy.ToolCallContent{ + ProviderExecuted: false, + ToolCallID: toolCallID, + ToolName: tc.Function.Name, + Input: tc.Function.Arguments, + }) + } + } for _, annotation := range choice.Message.Annotations { if annotation.Type == "url_citation" { content = append(content, fantasy.SourceContent{ @@ -298,12 +447,6 @@ func (o languageModel) Generate(ctx context.Context, call fantasy.Call) (*fantas } } - usage, providerMetadata := o.usageFunc(*response) - - mappedFinishReason := o.mapFinishReasonFunc(choice.FinishReason) - if len(choice.Message.ToolCalls) > 0 { - mappedFinishReason = fantasy.FinishReasonToolCalls - } return &fantasy.Response{ Content: content, Usage: usage, @@ -317,6 +460,7 @@ func (o languageModel) Generate(ctx context.Context, call fantasy.Call) (*fantas // Stream implements fantasy.LanguageModel. func (o languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.StreamResponse, error) { + capture := responseCapture{} params, warnings, err := o.prepareParams(call) if err != nil { return nil, err @@ -326,7 +470,7 @@ func (o languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.S IncludeUsage: openai.Bool(true), } - stream := o.client.Chat.Completions.NewStreaming(ctx, *params, append(callUARequestOptions(call), callHeadersRequestOptions(call)...)...) + stream := o.client.Chat.Completions.NewStreaming(ctx, *params, capture.requestOptions(o.headerFunc, append(callUARequestOptions(call), callHeadersRequestOptions(call)...))...) isActiveText := false toolCalls := make(map[int64]streamToolCall) @@ -358,6 +502,20 @@ func (o languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.S if len(chunk.Choices) == 0 { continue } + // The extra hook receives the whole chunk and iterates choices + // itself; calling it per choice would duplicate its events once + // per additional choice. It must run before the content/tool + // loop: when a batching host puts the reasoning tail and the + // first content/tool-call token in the same delta, the reasoning + // belongs before that content — yielding it after inverts the + // part order and breaks block-based consumers. + if o.streamExtraFunc != nil { + updatedContext, shouldContinue := o.streamExtraFunc(chunk, yield, extraContext) + if !shouldContinue { + return + } + extraContext = updatedContext + } for _, choice := range chunk.Choices { if choice.FinishReason != "" { finishReason = choice.FinishReason @@ -393,9 +551,6 @@ func (o languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.S for _, toolCallDelta := range choice.Delta.ToolCalls { if existingToolCall, ok := toolCalls[toolCallDelta.Index]; ok { - if existingToolCall.hasFinished { - continue - } if toolCallDelta.Function.Arguments != "" { existingToolCall.arguments += toolCallDelta.Function.Arguments if !yield(fantasy.StreamPart{ @@ -424,33 +579,6 @@ func (o languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.S return } - // A new tool call index means any previously opened - // calls are complete. Emit ToolInputEnd for each to - // preserve per-call framing during streaming so that - // consumers can reconstruct parallel tool calls from - // the stream order without buffering. Close them in - // index order for deterministic output. - priorIndices := make([]int64, 0, len(toolCalls)) - for idx := range toolCalls { - priorIndices = append(priorIndices, idx) - } - slices.Sort(priorIndices) - for _, idx := range priorIndices { - tc := toolCalls[idx] - if idx >= toolCallDelta.Index || tc.hasFinished { - continue - } - if tc.arguments == "" { - tc.arguments = "{}" - toolCalls[idx] = tc - } - if !yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeToolInputEnd, ID: tc.id}) { - return - } - tc.hasFinished = true - toolCalls[idx] = tc - } - if !yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeToolInputStart, ID: toolCallDelta.ID, @@ -477,16 +605,7 @@ func (o languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.S } } } - - if o.streamExtraFunc != nil { - updatedContext, shouldContinue := o.streamExtraFunc(chunk, yield, extraContext) - if !shouldContinue { - return - } - extraContext = updatedContext - } } - for _, choice := range chunk.Choices { if annotations := parseAnnotationsFromDelta(choice.Delta); len(annotations) > 0 { for _, annotation := range annotations { @@ -517,7 +636,63 @@ func (o languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.S } } + // Evaluate the finish reason before emitting tool calls so we can + // suppress them when the response was truncated (finish_reason=length). + // Emitting partial tool calls causes agents to dispatch them with + // invalid arguments before seeing the terminal reason. + mappedFinishReason := o.mapFinishReasonFunc(finishReason) + + // "Tool calls were seen" is not proof of a complete turn. Infer a + // tool-call turn only when the upstream said tool_calls/function_call + // explicitly (kept verbatim by the mapper) or sent no finish reason + // at all and every accumulated call's arguments parse as complete + // JSON. Terminal reasons that can cut output mid-call — length, + // content_filter, provider errors — must never be rewritten into a + // tool-call turn: dispatching their partial calls executes truncated + // input (CHARM-2020). + var missingFinishWithBadArgs bool + var missingFinish bool + if finishReason == "" && len(toolCalls) > 0 { + missingFinish = true + for _, tc := range toolCalls { + // A call with no arguments was cut before any argument + // arrived; filling in "{}" would invent arguments the model + // never sent. + if tc.arguments == "" || !json.Valid([]byte(tc.arguments)) { + missingFinishWithBadArgs = true + break + } + } + } + + if finishReason == "" && len(acc.Choices) > 0 && !missingFinishWithBadArgs { + if len(acc.Choices[0].Message.ToolCalls) > 0 { + mappedFinishReason = fantasy.FinishReasonToolCalls + } + } + suppressedWithToolCalls := (mappedFinishReason == fantasy.FinishReasonLength || + mappedFinishReason == fantasy.FinishReasonError || + mappedFinishReason == fantasy.FinishReasonContentFilter || + missingFinishWithBadArgs) && len(toolCalls) > 0 + + // A cut stream with unusable partial calls errors out before the + // finalizer runs: emitting ToolInputEnd after backfilling "{}" would + // present fabricated completed input to consumers (CHARM-2020). + if missingFinishWithBadArgs { + err := ctx.Err() + if err == nil { + err = fantasy.NewIncompleteStreamError() + } + yield(fantasy.StreamPart{ + Type: fantasy.StreamPartTypeError, + Error: err, + }) + return + } + // Finalize tool calls in index order after the stream completes. + // When truncated, skip ToolCall parts to prevent agents from + // dispatching calls with incomplete arguments. indices := make([]int64, 0, len(toolCalls)) for idx := range toolCalls { indices = append(indices, idx) @@ -534,8 +709,10 @@ func (o languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.S return } } - if !yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeToolCall, ID: tc.id, ToolCallName: tc.name, ToolCallInput: tc.arguments}) { - return + if !suppressedWithToolCalls { + if !yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeToolCall, ID: tc.id, ToolCallName: tc.name, ToolCallInput: tc.arguments}) { + return + } } tc.hasFinished = true toolCalls[idx] = tc @@ -559,13 +736,6 @@ func (o languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.S } } } - mappedFinishReason := o.mapFinishReasonFunc(finishReason) - if len(acc.Choices) > 0 { - choice := acc.Choices[0] - if len(choice.Message.ToolCalls) > 0 { - mappedFinishReason = fantasy.FinishReasonToolCalls - } - } // Truncated stream: upstream closed without finish_reason and we // can't infer a tool-call turn. Surface as a retryable error so // the retry middleware re-runs the step. @@ -580,6 +750,25 @@ func (o languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.S }) return } + if missingFinish && !missingFinishWithBadArgs { + yield(fantasy.StreamPart{ + Type: fantasy.StreamPartTypeWarnings, + Warnings: []fantasy.CallWarning{{ + Type: fantasy.CallWarningTypeOther, + Message: "stream ended without finish_reason; assuming tool-call turn", + }}, + }) + } + if suppressedWithToolCalls && !missingFinishWithBadArgs { + yield(fantasy.StreamPart{ + Type: fantasy.StreamPartTypeWarnings, + Warnings: []fantasy.CallWarning{{ + Type: fantasy.CallWarningTypeOther, + Message: "tool calls were returned but the turn ended abnormally (token limit, content filter, or provider error); arguments may be truncated", + }}, + }) + } + o.applyHeadersStream(capture.header(), &providerMetadata) yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeFinish, Usage: usage, diff --git a/providers/openai/language_model_hooks.go b/providers/openai/language_model_hooks.go index d5169cddd..234a83b1a 100644 --- a/providers/openai/language_model_hooks.go +++ b/providers/openai/language_model_hooks.go @@ -3,6 +3,7 @@ package openai import ( "encoding/base64" "fmt" + "net/http" "strings" "charm.land/fantasy" @@ -32,6 +33,12 @@ type LanguageModelStreamUsageFunc = func(chunk openai.ChatCompletionChunk, ctx m // LanguageModelStreamProviderMetadataFunc is a function that handles stream provider metadata for the language model. type LanguageModelStreamProviderMetadataFunc = func(choice openai.ChatCompletionChoice, metadata fantasy.ProviderMetadata) fantasy.ProviderMetadata +// LanguageModelHeaderFunc is a function that receives the HTTP response +// headers of a completed call. It may copy headers of interest into the +// provider metadata, so they are surfaced to callers through +// [fantasy.Response] and the terminal [fantasy.StreamPart]. +type LanguageModelHeaderFunc = func(header http.Header, metadata *ProviderMetadata) + // LanguageModelToPromptFunc is a function that handles converting fantasy prompts to openai sdk messages. type LanguageModelToPromptFunc = func(prompt fantasy.Prompt, provider, model string) ([]openai.ChatCompletionMessageParamUnion, []fantasy.CallWarning) @@ -194,11 +201,29 @@ func DefaultMapFinishReasonFunc(finishReason string) fantasy.FinishReason { return fantasy.FinishReasonContentFilter case "function_call", "tool_calls": return fantasy.FinishReasonToolCalls + case "insufficient_system_resource": + // DeepSeek-family upstreams report this when the request could not + // be served; the partial output (including open tool calls) must be + // treated like a truncation, never a completed turn. + return fantasy.FinishReasonError default: return fantasy.FinishReasonUnknown } } +// FoldDisjointReasoning keeps output tokens all-inclusive. OpenAI reports +// reasoning as a subset of completion_tokens, but some OpenAI-shaped gateways +// report it disjointly (Vercel AI Gateway streaming Gemini, for one). +// Reasoning exceeding output can only happen in the disjoint shape — a subset +// can never exceed its parent — so folding it in is always safe, and total +// follows output. +func FoldDisjointReasoning(output, reasoning, total int64) (int64, int64) { + if reasoning > output { + return output + reasoning, total + reasoning + } + return output, total +} + // DefaultUsageFunc is the default implementation for calculating usage. func DefaultUsageFunc(response openai.ChatCompletion) (fantasy.Usage, fantasy.ProviderOptionsData) { completionTokenDetails := response.Usage.CompletionTokensDetails @@ -223,11 +248,16 @@ func DefaultUsageFunc(response openai.ChatCompletion) (fantasy.Usage, fantasy.Pr } // OpenAI reports prompt_tokens INCLUDING cached tokens. Subtract to avoid double-counting. inputTokens := max(response.Usage.PromptTokens-promptTokenDetails.CachedTokens, 0) + outputTokens, totalTokens := FoldDisjointReasoning( + response.Usage.CompletionTokens, + completionTokenDetails.ReasoningTokens, + response.Usage.TotalTokens, + ) providerMetadata.ExtraFields = ExtractExtraFields(response.Usage.JSON.ExtraFields) return fantasy.Usage{ InputTokens: inputTokens, - OutputTokens: response.Usage.CompletionTokens, - TotalTokens: response.Usage.TotalTokens, + OutputTokens: outputTokens, + TotalTokens: totalTokens, ReasoningTokens: completionTokenDetails.ReasoningTokens, CacheReadTokens: promptTokenDetails.CachedTokens, }, providerMetadata @@ -252,10 +282,15 @@ func DefaultStreamUsageFunc(chunk openai.ChatCompletionChunk, _ map[string]any, promptTokenDetails := chunk.Usage.PromptTokensDetails // OpenAI reports prompt_tokens INCLUDING cached tokens. Subtract to avoid double-counting. inputTokens := max(chunk.Usage.PromptTokens-promptTokenDetails.CachedTokens, 0) + outputTokens, totalTokens := FoldDisjointReasoning( + chunk.Usage.CompletionTokens, + completionTokenDetails.ReasoningTokens, + chunk.Usage.TotalTokens, + ) usage := fantasy.Usage{ InputTokens: inputTokens, - OutputTokens: chunk.Usage.CompletionTokens, - TotalTokens: chunk.Usage.TotalTokens, + OutputTokens: outputTokens, + TotalTokens: totalTokens, ReasoningTokens: completionTokenDetails.ReasoningTokens, CacheReadTokens: promptTokenDetails.CachedTokens, } @@ -297,13 +332,9 @@ func DefaultStreamProviderMetadataFunc(choice openai.ChatCompletionChoice, metad func DefaultToPrompt(prompt fantasy.Prompt, _, _ string) ([]openai.ChatCompletionMessageParamUnion, []fantasy.CallWarning) { var messages []openai.ChatCompletionMessageParamUnion var warnings []fantasy.CallWarning - // All tool replies in a batch must precede synthetic user media. - var pendingToolMedia []openai.ChatCompletionMessageParamUnion + var media ToolRunBuffer for _, msg := range prompt { - if msg.Role != fantasy.MessageRoleTool { - messages = append(messages, pendingToolMedia...) - pendingToolMedia = nil - } + messages = media.Role(msg.Role, messages) switch msg.Role { case fantasy.MessageRoleSystem: var systemPromptParts []string @@ -467,7 +498,7 @@ func DefaultToPrompt(prompt fantasy.Prompt, _, _ string) ([]openai.ChatCompletio } } } - if !hasVisibleUserContent(content) { + if !HasVisibleUserContent(content) { warnings = append(warnings, fantasy.CallWarning{ Type: fantasy.CallWarningTypeOther, Message: "dropping empty user message (contains neither user-facing content nor tool results)", @@ -528,7 +559,7 @@ func DefaultToPrompt(prompt fantasy.Prompt, _, _ string) ([]openai.ChatCompletio }) } } - if !hasVisibleAssistantContent(&assistantMsg) { + if !HasVisibleAssistantContent(&assistantMsg) { warnings = append(warnings, fantasy.CallWarning{ Type: fantasy.CallWarningTypeOther, Message: "dropping empty assistant message (contains neither user-facing content nor tool calls)", @@ -539,132 +570,19 @@ func DefaultToPrompt(prompt fantasy.Prompt, _, _ string) ([]openai.ChatCompletio OfAssistant: &assistantMsg, }) case fantasy.MessageRoleTool: - for _, c := range msg.Content { - if c.GetType() != fantasy.ContentTypeToolResult { - warnings = append(warnings, fantasy.CallWarning{ - Type: fantasy.CallWarningTypeOther, - Message: "tool message can only have tool result content", - }) - continue - } - - toolResultPart, ok := fantasy.AsContentType[fantasy.ToolResultPart](c) - if !ok { - warnings = append(warnings, fantasy.CallWarning{ - Type: fantasy.CallWarningTypeOther, - Message: "tool message result part does not have the right type", - }) - continue - } - - switch toolResultPart.Output.GetType() { - case fantasy.ToolResultContentTypeText: - output, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentText](toolResultPart.Output) - if !ok { - warnings = append(warnings, fantasy.CallWarning{ - Type: fantasy.CallWarningTypeOther, - Message: "tool result output does not have the right type", - }) - continue - } - messages = append(messages, openai.ToolMessage(output.Text, toolResultPart.ToolCallID)) - case fantasy.ToolResultContentTypeError: - // TODO: check if better handling is needed - output, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentError](toolResultPart.Output) - if !ok { - warnings = append(warnings, fantasy.CallWarning{ - Type: fantasy.CallWarningTypeOther, - Message: "tool result output does not have the right type", - }) - continue - } - messages = append(messages, openai.ToolMessage(output.Error.Error(), toolResultPart.ToolCallID)) - case fantasy.ToolResultContentTypeMedia: - output, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentMedia](toolResultPart.Output) - if !ok { - warnings = append(warnings, fantasy.CallWarning{ - Type: fantasy.CallWarningTypeOther, - Message: "tool result output does not have the right type", - }) - continue - } - // OpenAI Chat Completions tool messages cannot carry image - // or audio content directly; see ToolResultMediaMessages. - mediaMessages, mediaWarnings := ToolResultMediaMessages(output, toolResultPart.ToolCallID) - messages = append(messages, mediaMessages[0]) - pendingToolMedia = append(pendingToolMedia, mediaMessages[1:]...) - warnings = append(warnings, mediaWarnings...) - default: - warnings = append(warnings, fantasy.CallWarning{ - Type: fantasy.CallWarningTypeOther, - Message: fmt.Sprintf("tool result output type %q not supported", toolResultPart.Output.GetType()), - }) - } - } - } - } - return append(messages, pendingToolMedia...), warnings -} - -// ToolResultMediaMessages maps a tool-result media output to the chat -// completions messages that convey it. OpenAI tool messages can only carry -// text, so this emits a text tool message (using any accompanying text, or a -// placeholder describing the media) to keep the tool_call/tool_result pairing -// valid, followed by a synthetic user message holding the actual image or -// audio content part so vision- and audio-capable models can see it. -// -// Unsupported media types produce only the text tool message plus a warning. -// This is shared with OpenAI-compatible providers, which face the same -// constraint. -func ToolResultMediaMessages(output fantasy.ToolResultOutputContentMedia, toolCallID string) ([]openai.ChatCompletionMessageParamUnion, []fantasy.CallWarning) { - placeholder := output.Text - if placeholder == "" { - placeholder = fmt.Sprintf("The tool returned %s content; see the following user message.", output.MediaType) - } - messages := []openai.ChatCompletionMessageParamUnion{openai.ToolMessage(placeholder, toolCallID)} - - mediaPart, warning, emit := toolResultMediaUserPart(output) - if warning != nil { - return messages, []fantasy.CallWarning{*warning} - } - if emit { - messages = append(messages, openai.UserMessage([]openai.ChatCompletionContentPartUnionParam{mediaPart})) - } - return messages, nil -} - -// toolResultMediaUserPart maps a tool-result media output to an OpenAI chat -// completions user content part. It returns the content part, an optional -// warning, and whether the caller should emit the returned part. -func toolResultMediaUserPart(output fantasy.ToolResultOutputContentMedia) (openai.ChatCompletionContentPartUnionParam, *fantasy.CallWarning, bool) { - switch { - case strings.HasPrefix(output.MediaType, "image/"): - data := "data:" + output.MediaType + ";base64," + output.Data - imageBlock := openai.ChatCompletionContentPartImageParam{ - ImageURL: openai.ChatCompletionContentPartImageImageURLParam{URL: data}, + toolMessages, deferred, toolWarnings := ToolMessages(msg, nil) + messages = append(messages, toolMessages...) + media.Defer(deferred...) + warnings = append(warnings, toolWarnings...) } - return openai.ChatCompletionContentPartUnionParam{OfImageURL: &imageBlock}, nil, true - case output.MediaType == "audio/wav", output.MediaType == "audio/mpeg", output.MediaType == "audio/mp3": - format := "wav" - if output.MediaType != "audio/wav" { - format = "mp3" - } - audioBlock := openai.ChatCompletionContentPartInputAudioParam{ - InputAudio: openai.ChatCompletionContentPartInputAudioInputAudioParam{ - Data: output.Data, - Format: format, - }, - } - return openai.ChatCompletionContentPartUnionParam{OfInputAudio: &audioBlock}, nil, true - default: - return openai.ChatCompletionContentPartUnionParam{}, &fantasy.CallWarning{ - Type: fantasy.CallWarningTypeOther, - Message: fmt.Sprintf("tool result media type %s not supported, sending text placeholder only", output.MediaType), - }, false } + return media.Close(messages), warnings } -func hasVisibleUserContent(content []openai.ChatCompletionContentPartUnionParam) bool { +// HasVisibleUserContent reports whether a user message carries anything the +// model can actually see. A message that converts to no visible parts must be +// dropped rather than sent: the API rejects a content-less user message. +func HasVisibleUserContent(content []openai.ChatCompletionContentPartUnionParam) bool { for _, part := range content { if part.OfText != nil || part.OfImageURL != nil || part.OfInputAudio != nil || part.OfFile != nil { return true @@ -673,12 +591,15 @@ func hasVisibleUserContent(content []openai.ChatCompletionContentPartUnionParam) return false } -func hasVisibleAssistantContent(msg *openai.ChatCompletionAssistantMessageParam) bool { - // Check if there's text content +// HasVisibleAssistantContent reports whether an assistant message carries text +// or tool calls. Reasoning alone is not enough: it travels in provider-specific +// extra fields, and strict upstreams reject a message holding only those +// ("content or tool_calls must be set"), then repeat that rejection on every +// later request because the message stays in history (charmbracelet/crush#3794). +func HasVisibleAssistantContent(msg *openai.ChatCompletionAssistantMessageParam) bool { if !param.IsOmitted(msg.Content.OfString) || len(msg.Content.OfArrayOfContentParts) > 0 { return true } - // Check if there are tool calls if len(msg.ToolCalls) > 0 { return true } diff --git a/providers/openai/openai.go b/providers/openai/openai.go index 35847a68a..918932ba8 100644 --- a/providers/openai/openai.go +++ b/providers/openai/openai.go @@ -204,7 +204,10 @@ func (o *provider) LanguageModel(_ context.Context, modelID string) (fantasy.Lan if objectMode == fantasy.ObjectModeJSON { objectMode = fantasy.ObjectModeAuto } - return newResponsesLanguageModel(modelID, o.options.name, client, objectMode, o.options.reasoningModelFunc), nil + // The responses language model has its own parameter and usage + // handling; of the language model options, only the header hook + // is shared with the chat completions model. + return newResponsesLanguageModel(modelID, o.options.name, client, objectMode, o.options.reasoningModelFunc, languageModelHeaderFunc(o.options.languageModelOptions)), nil } languageModelOptions := append([]LanguageModelOption{}, o.options.languageModelOptions...) diff --git a/providers/openai/openai_test.go b/providers/openai/openai_test.go index 5be109340..38376246b 100644 --- a/providers/openai/openai_test.go +++ b/providers/openai/openai_test.go @@ -1290,6 +1290,102 @@ func TestDoGenerate(t *testing.T) { require.Equal(t, `{"value":"Spark"}`, toolCall.Input) }) + t.Run("should preserve finish_reason length when tool calls are present", func(t *testing.T) { + t.Parallel() + + server := newMockServer() + defer server.close() + + server.prepareJSONResponse(map[string]any{ + "finish_reason": "length", + "tool_calls": []map[string]any{ + { + "id": "call_truncated", + "type": "function", + "function": map[string]any{ + "name": "test-tool", + "arguments": `{"value":"trunc`, + }, + }, + }, + }) + + provider, err := New( + WithAPIKey("test-api-key"), + WithBaseURL(server.server.URL), + ) + require.NoError(t, err) + model, _ := provider.LanguageModel(t.Context(), "gpt-3.5-turbo") + + result, err := model.Generate(context.Background(), fantasy.Call{ + Prompt: testPrompt, + Tools: []fantasy.Tool{ + fantasy.FunctionTool{ + Name: "test-tool", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{"value": map[string]any{"type": "string"}}, + }, + }, + }, + }) + + require.NoError(t, err) + require.Equal(t, fantasy.FinishReasonLength, result.FinishReason) + require.NotEmpty(t, result.Warnings) + require.Contains(t, result.Warnings[0].Message, "token limit") + // Truncated tool calls must not appear in content so agents + // don't dispatch them with incomplete arguments. + for _, c := range result.Content { + require.NotEqual(t, fantasy.ContentTypeToolCall, c.GetType(), "truncated tool call should be suppressed") + } + }) + + t.Run("should override to tool_calls when finish_reason is stop", func(t *testing.T) { + t.Parallel() + + server := newMockServer() + defer server.close() + + server.prepareJSONResponse(map[string]any{ + "finish_reason": "stop", + "tool_calls": []map[string]any{ + { + "id": "call_ok", + "type": "function", + "function": map[string]any{ + "name": "test-tool", + "arguments": `{"value":"ok"}`, + }, + }, + }, + }) + + provider, err := New( + WithAPIKey("test-api-key"), + WithBaseURL(server.server.URL), + ) + require.NoError(t, err) + model, _ := provider.LanguageModel(t.Context(), "gpt-3.5-turbo") + + result, err := model.Generate(context.Background(), fantasy.Call{ + Prompt: testPrompt, + Tools: []fantasy.Tool{ + fantasy.FunctionTool{ + Name: "test-tool", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{"value": map[string]any{"type": "string"}}, + }, + }, + }, + }) + + require.NoError(t, err) + require.Equal(t, fantasy.FinishReasonToolCalls, result.FinishReason) + require.Empty(t, result.Warnings) + }) + t.Run("should handle ToolChoiceRequired", func(t *testing.T) { t.Parallel() @@ -2634,14 +2730,14 @@ func TestDoStream(t *testing.T) { parts, err := collectStreamParts(stream) require.NoError(t, err) - // The framing contract: a tool call must be fully delimited - // (Start -> Deltas -> End) before the next one opens. No two calls - // may be open at once, and no delta may arrive for an already-ended - // call. This is what lets an order/index-keyed consumer reconstruct - // parallel calls without buffering the whole stream. - var openID string // currently open tool call, "" if none + // The integrity contract: every opened call is attributed deltas for + // its own id only, every call is ended exactly once, and every + // ToolCall carries the fully accumulated input. Interleaved + // providers may keep sending deltas for an earlier call after the + // next one has started, so an end is not required to precede the + // next start — but no delta may arrive after its call's end. + started := map[string]bool{} ended := map[string]bool{} - seenStart := map[string]bool{} argsByID := map[string]*strings.Builder{} nameByID := map[string]string{} var order []string @@ -2649,24 +2745,19 @@ func TestDoStream(t *testing.T) { for _, part := range parts { switch part.Type { case fantasy.StreamPartTypeToolInputStart: - require.Empty(t, openID, - "tool call %s opened while %s still open (interleaved framing)", part.ID, openID) - require.False(t, seenStart[part.ID], "duplicate start for %s", part.ID) - openID = part.ID - seenStart[part.ID] = true + require.False(t, started[part.ID], "duplicate start for %s", part.ID) + started[part.ID] = true nameByID[part.ID] = part.ToolCallName argsByID[part.ID] = &strings.Builder{} order = append(order, part.ID) case fantasy.StreamPartTypeToolInputDelta: - require.Equal(t, openID, part.ID, - "delta for %s but open call is %q", part.ID, openID) + require.True(t, started[part.ID], "delta for never-started call %s", part.ID) require.False(t, ended[part.ID], "delta after end for %s", part.ID) argsByID[part.ID].WriteString(part.Delta) case fantasy.StreamPartTypeToolInputEnd: - require.Equal(t, openID, part.ID, - "end for %s but open call is %q", part.ID, openID) + require.True(t, started[part.ID], "end for never-started call %s", part.ID) + require.False(t, ended[part.ID], "duplicate end for %s", part.ID) ended[part.ID] = true - openID = "" case fantasy.StreamPartTypeToolCall: require.True(t, ended[part.ID], "tool call %s finalized before end", part.ID) nameByID[part.ID] = part.ToolCallName @@ -3487,6 +3578,61 @@ func TestDoStream(t *testing.T) { require.NotNil(t, finish) require.Equal(t, fantasy.FinishReasonToolCalls, finish.FinishReason) }) + + t.Run("should preserve finish_reason length with tool calls during streaming", func(t *testing.T) { + t.Parallel() + + server := newStreamingMockServer() + defer server.close() + + server.chunks = []string{ + `data: {"id":"chatcmpl-truncated","object":"chat.completion.chunk","created":1,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_trunc","type":"function","function":{"name":"test-tool","arguments":""}}]},"finish_reason":null}]}` + "\n\n", + `data: {"id":"chatcmpl-truncated","object":"chat.completion.chunk","created":1,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"val"}}]},"finish_reason":null}]}` + "\n\n", + `data: {"id":"chatcmpl-truncated","object":"chat.completion.chunk","created":1,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{},"finish_reason":"length"}]}` + "\n\n", + "data: [DONE]\n\n", + } + + provider, err := New( + WithAPIKey("test-api-key"), + WithBaseURL(server.server.URL), + ) + require.NoError(t, err) + model, _ := provider.LanguageModel(t.Context(), "gpt-3.5-turbo") + + stream, err := model.Stream(context.Background(), fantasy.Call{ + Prompt: testPrompt, + Tools: []fantasy.Tool{fantasy.FunctionTool{ + Name: "test-tool", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{"val": map[string]any{"type": "string"}}, + }, + }}, + }) + require.NoError(t, err) + + parts, err := collectStreamParts(stream) + require.NoError(t, err) + + var finish *fantasy.StreamPart + var hasWarning bool + var toolCallCount int + for i, part := range parts { + if part.Type == fantasy.StreamPartTypeFinish { + finish = &parts[i] + } + if part.Type == fantasy.StreamPartTypeWarnings { + hasWarning = true + } + if part.Type == fantasy.StreamPartTypeToolCall { + toolCallCount++ + } + } + require.NotNil(t, finish) + require.Equal(t, fantasy.FinishReasonLength, finish.FinishReason) + require.True(t, hasWarning, "expected truncation warning") + require.Zero(t, toolCallCount, "truncated tool calls must not be emitted") + }) } func TestDefaultToPrompt_DropsEmptyMessages(t *testing.T) { @@ -3900,6 +4046,34 @@ func TestParseContextTooLargeError(t *testing.T) { wantErr: true, wantMax: 245760, }, + { + name: "matches baseten format", + message: "Input length 265059 exceeds the maximum allowed input length of 262112 tokens.", + wantErr: true, + wantUsed: 265059, + wantMax: 262112, + }, + { + name: "matches fireworks format", + message: "The prompt is too long: 1261484, model maximum context length: 1048573", + wantErr: true, + wantUsed: 1261484, + wantMax: 1048573, + }, + { + name: "matches parasail format", + message: "this model's maximum context length is 1048576 tokens, but the prompt contains 1210787 input tokens", + wantErr: true, + wantUsed: 1210787, + wantMax: 1048576, + }, + { + name: "matches parasail format with requested output", + message: "This model's maximum context length is 1048576 tokens. However, you requested 16000 output tokens and your prompt contains at least 1032577 input tokens, for a total of at least 1048577 tokens. Please reduce the length of the input prompt or the number of requested output tokens. (parameter=input_tokens, value=1032577)", + wantErr: true, + wantUsed: 1032577, + wantMax: 1048576, + }, { name: "matches vercel format", message: "Input too long: 518063 input tokens, limit is 262144 for this model", @@ -5075,8 +5249,7 @@ func requireNotRetryableUnexpectedEOF(t *testing.T, err error) { t.Helper() require.NotErrorIs(t, err, io.ErrUnexpectedEOF) - var providerErr *fantasy.ProviderError - if errors.As(err, &providerErr) { + if providerErr, ok := errors.AsType[*fantasy.ProviderError](err); ok { require.False(t, providerErr.IsRetryable()) require.NotErrorIs(t, providerErr.Cause, io.ErrUnexpectedEOF) } @@ -5519,3 +5692,102 @@ func TestResponsesStream_TruncatedWithoutResponseCompleted(t *testing.T) { require.True(t, providerErr.IsRetryable()) require.ErrorIs(t, providerErr.Cause, io.ErrUnexpectedEOF) } + +func TestResponsesGenerate_TruncatedToolCalls(t *testing.T) { + t.Parallel() + + server := newMockServer() + defer server.close() + server.response = map[string]any{ + "id": "resp_trunc", + "object": "response", + "model": "gpt-4.1", + "output": []any{ + map[string]any{ + "type": "function_call", + "id": "fc_trunc", + "call_id": "call_trunc", + "name": "test-tool", + "arguments": `{"value":"trunc`, + "status": "completed", + }, + }, + "status": "incomplete", + "incomplete_details": map[string]any{"reason": "max_output_tokens"}, + "usage": map[string]any{ + "input_tokens": 10, + "output_tokens": 20, + "total_tokens": 30, + }, + } + + model := newResponsesProvider(t, server.server.URL) + + resp, err := model.Generate(context.Background(), fantasy.Call{ + Prompt: testPrompt, + Tools: []fantasy.Tool{fantasy.FunctionTool{ + Name: "test-tool", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{"value": map[string]any{"type": "string"}}, + }, + }}, + }) + require.NoError(t, err) + require.Equal(t, fantasy.FinishReasonLength, resp.FinishReason) + require.NotEmpty(t, resp.Warnings) + require.Contains(t, resp.Warnings[0].Message, "token limit") + for _, c := range resp.Content { + require.NotEqual(t, fantasy.ContentTypeToolCall, c.GetType(), "truncated tool call should be suppressed") + } +} + +func TestResponsesStream_TruncatedToolCalls(t *testing.T) { + t.Parallel() + + chunks := []string{ + responsesSSEEvent("response.created", `{"type":"response.created","response":{"id":"resp_trunc","status":"in_progress","output":[]}}`), + responsesSSEEvent("response.output_item.added", `{"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"fc_trunc","call_id":"call_trunc","name":"test-tool","status":"in_progress","arguments":""}}`), + responsesSSEEvent("response.function_call_arguments.delta", `{"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\"value\":\"tr"}`), + responsesSSEEvent("response.output_item.done", `{"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"fc_trunc","call_id":"call_trunc","name":"test-tool","status":"completed","arguments":"{\"value\":\"tr"}}`), + responsesSSEEvent("response.incomplete", `{"type":"response.incomplete","response":{"id":"resp_trunc","status":"incomplete","output":[],"incomplete_details":{"reason":"max_output_tokens"},"usage":{"input_tokens":10,"output_tokens":20,"total_tokens":30}}}`), + } + + sms := newStreamingMockServer() + defer sms.close() + sms.chunks = chunks + + model := newResponsesProvider(t, sms.server.URL) + + stream, err := model.Stream(context.Background(), fantasy.Call{ + Prompt: testPrompt, + Tools: []fantasy.Tool{fantasy.FunctionTool{ + Name: "test-tool", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{"val": map[string]any{"type": "string"}}, + }, + }}, + }) + require.NoError(t, err) + + var parts []fantasy.StreamPart + stream(func(part fantasy.StreamPart) bool { + parts = append(parts, part) + return true + }) + + var finish *fantasy.StreamPart + var hasWarning bool + for i, part := range parts { + if part.Type == fantasy.StreamPartTypeFinish { + finish = &parts[i] + } + if part.Type == fantasy.StreamPartTypeWarnings { + hasWarning = true + } + } + require.NotNil(t, finish) + require.Equal(t, fantasy.FinishReasonLength, finish.FinishReason) + require.True(t, hasWarning, "expected truncation warning") +} diff --git a/providers/openai/responses_content_filter_test.go b/providers/openai/responses_content_filter_test.go new file mode 100644 index 000000000..c44b7e25c --- /dev/null +++ b/providers/openai/responses_content_filter_test.go @@ -0,0 +1,114 @@ +package openai + +import ( + "context" + "testing" + + "charm.land/fantasy" + "github.com/stretchr/testify/require" +) + +// The Responses API documents `incomplete_details.reason` as one of +// "max_output_tokens", "max_messages", "content_filter", "steered". A turn cut +// short by a content filter can carry a partially-serialized function_call, and +// fantasy's agent suppresses dispatch on FinishReasonContentFilter +// (agent.go: `abnormalFinish`). Rewriting that reason into FinishReasonToolCalls +// makes a truncated call look like a complete tool-call turn again. +func TestResponsesGenerate_ContentFilterToolCallsKeepFilterReason(t *testing.T) { + t.Parallel() + + server := newMockServer() + defer server.close() + server.response = map[string]any{ + "id": "resp_filter", + "object": "response", + "model": "gpt-4.1", + "output": []any{ + map[string]any{ + "type": "function_call", + "id": "fc_filter", + "call_id": "call_filter", + "name": "test-tool", + "arguments": `{"value":"trunc`, + "status": "completed", + }, + }, + "status": "incomplete", + "incomplete_details": map[string]any{"reason": "content_filter"}, + "usage": map[string]any{ + "input_tokens": 10, + "output_tokens": 20, + "total_tokens": 30, + }, + } + + model := newResponsesProvider(t, server.server.URL) + + resp, err := model.Generate(context.Background(), fantasy.Call{ + Prompt: testPrompt, + Tools: []fantasy.Tool{fantasy.FunctionTool{ + Name: "test-tool", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{"value": map[string]any{"type": "string"}}, + }, + }}, + }) + require.NoError(t, err) + require.Equal(t, fantasy.FinishReasonContentFilter, resp.FinishReason) + require.NotEmpty(t, resp.Warnings) + require.Contains(t, resp.Warnings[0].Message, "arguments may be truncated") + for _, c := range resp.Content { + require.NotEqual(t, fantasy.ContentTypeToolCall, c.GetType(), "truncated tool call should be suppressed") + } +} + +func TestResponsesStream_ContentFilterToolCallsKeepFilterReason(t *testing.T) { + t.Parallel() + + chunks := []string{ + responsesSSEEvent("response.created", `{"type":"response.created","response":{"id":"resp_filter","status":"in_progress","output":[]}}`), + responsesSSEEvent("response.output_item.added", `{"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"fc_filter","call_id":"call_filter","name":"test-tool","status":"in_progress","arguments":""}}`), + responsesSSEEvent("response.function_call_arguments.delta", `{"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\"value\":\"tr"}`), + responsesSSEEvent("response.output_item.done", `{"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"fc_filter","call_id":"call_filter","name":"test-tool","status":"completed","arguments":"{\"value\":\"tr"}}`), + responsesSSEEvent("response.incomplete", `{"type":"response.incomplete","response":{"id":"resp_filter","status":"incomplete","output":[],"incomplete_details":{"reason":"content_filter"},"usage":{"input_tokens":10,"output_tokens":20,"total_tokens":30}}}`), + } + + sms := newStreamingMockServer() + defer sms.close() + sms.chunks = chunks + + model := newResponsesProvider(t, sms.server.URL) + + stream, err := model.Stream(context.Background(), fantasy.Call{ + Prompt: testPrompt, + Tools: []fantasy.Tool{fantasy.FunctionTool{ + Name: "test-tool", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{"value": map[string]any{"type": "string"}}, + }, + }}, + }) + require.NoError(t, err) + + var parts []fantasy.StreamPart + stream(func(part fantasy.StreamPart) bool { + parts = append(parts, part) + return true + }) + + var finish *fantasy.StreamPart + var hasWarning bool + for i, part := range parts { + if part.Type == fantasy.StreamPartTypeFinish { + finish = &parts[i] + } + if part.Type == fantasy.StreamPartTypeWarnings { + hasWarning = true + } + } + require.NotNil(t, finish) + require.Equal(t, fantasy.FinishReasonContentFilter, finish.FinishReason) + require.True(t, hasWarning, "expected truncation warning") +} diff --git a/providers/openai/responses_language_model.go b/providers/openai/responses_language_model.go index ccbf2caa0..bcb432ebd 100644 --- a/providers/openai/responses_language_model.go +++ b/providers/openai/responses_language_model.go @@ -7,6 +7,8 @@ import ( "errors" "fmt" "io" + "maps" + "net/http" "reflect" "slices" "strings" @@ -29,19 +31,49 @@ type responsesLanguageModel struct { client openai.Client objectMode fantasy.ObjectMode reasoningModelFunc func(modelID string) bool + headerFunc LanguageModelHeaderFunc } // newResponsesLanguageModel implements a responses api model. -func newResponsesLanguageModel(modelID string, provider string, client openai.Client, objectMode fantasy.ObjectMode, reasoningModelFunc func(modelID string) bool) responsesLanguageModel { +func newResponsesLanguageModel(modelID string, provider string, client openai.Client, objectMode fantasy.ObjectMode, reasoningModelFunc func(modelID string) bool, headerFunc LanguageModelHeaderFunc) responsesLanguageModel { return responsesLanguageModel{ modelID: modelID, provider: provider, client: client, objectMode: objectMode, reasoningModelFunc: reasoningModelFunc, + headerFunc: headerFunc, } } +// applyHeaders invokes the configured header func against a chat +// completions metadata sink and copies the resulting extra fields into +// the responses provider metadata, creating the entry when the call +// carried none. It is a no-op when no header func is configured or no +// response was captured. +func (o responsesLanguageModel) applyHeaders(header http.Header, metadata *fantasy.ProviderMetadata) { + if o.headerFunc == nil || header == nil { + return + } + sink := &ProviderMetadata{} + o.headerFunc(header, sink) + if len(sink.ExtraFields) == 0 { + return + } + if *metadata == nil { + *metadata = fantasy.ProviderMetadata{} + } + responsesMetadata, ok := (*metadata)[Name].(*ResponsesProviderMetadata) + if !ok { + responsesMetadata = &ResponsesProviderMetadata{} + (*metadata)[Name] = responsesMetadata + } + if responsesMetadata.ExtraFields == nil { + responsesMetadata.ExtraFields = make(map[string]json.RawMessage) + } + maps.Copy(responsesMetadata.ExtraFields, sink.ExtraFields) +} + func (o responsesLanguageModel) Model() string { return o.modelID } @@ -407,10 +439,15 @@ func responsesProviderMetadata(responseID string) fantasy.ProviderMetadata { func responsesUsage(resp responses.Response) fantasy.Usage { // OpenAI reports input_tokens INCLUDING cached tokens. Subtract to avoid double-counting. inputTokens := max(resp.Usage.InputTokens-resp.Usage.InputTokensDetails.CachedTokens, 0) + outputTokens, totalTokens := FoldDisjointReasoning( + resp.Usage.OutputTokens, + resp.Usage.OutputTokensDetails.ReasoningTokens, + resp.Usage.InputTokens+resp.Usage.OutputTokens, + ) usage := fantasy.Usage{ InputTokens: inputTokens, - OutputTokens: resp.Usage.OutputTokens, - TotalTokens: resp.Usage.InputTokens + resp.Usage.OutputTokens, + OutputTokens: outputTokens, + TotalTokens: totalTokens, } if resp.Usage.OutputTokensDetails.ReasoningTokens != 0 { usage.ReasoningTokens = resp.Usage.OutputTokensDetails.ReasoningTokens @@ -1073,12 +1110,13 @@ func toResponsesTools(tools []fantasy.Tool, toolChoice *fantasy.ToolChoice, opti } func (o responsesLanguageModel) Generate(ctx context.Context, call fantasy.Call) (*fantasy.Response, error) { + capture := responseCapture{} params, warnings, err := o.prepareParams(call) if err != nil { return nil, err } - response, err := o.client.Responses.New(ctx, *params, append(callUARequestOptions(call), callHeadersRequestOptions(call)...)...) + response, err := o.client.Responses.New(ctx, *params, capture.requestOptions(o.headerFunc, append(callUARequestOptions(call), callHeadersRequestOptions(call)...))...) if err != nil { return nil, toProviderErr(err) } @@ -1096,6 +1134,7 @@ func (o responsesLanguageModel) Generate(ctx context.Context, call fantasy.Call) var content []fantasy.Content hasFunctionCall := false + var pendingFunctionCalls []fantasy.ToolCallContent for _, outputItem := range response.Output { switch outputItem.Type { @@ -1139,7 +1178,7 @@ func (o responsesLanguageModel) Generate(ctx context.Context, call fantasy.Call) case "function_call": hasFunctionCall = true - content = append(content, fantasy.ToolCallContent{ + pendingFunctionCalls = append(pendingFunctionCalls, fantasy.ToolCallContent{ ProviderExecuted: false, ToolCallID: outputItem.CallID, ToolName: outputItem.Name, @@ -1206,40 +1245,75 @@ func (o responsesLanguageModel) Generate(ctx context.Context, call fantasy.Call) usage := responsesUsage(*response) finishReason := mapResponsesFinishReason(response.IncompleteDetails.Reason, hasFunctionCall) + truncatedWithToolCalls := hasFunctionCall && abnormalFinishReason(finishReason) + if truncatedWithToolCalls { + warnings = append(warnings, fantasy.CallWarning{ + Type: fantasy.CallWarningTypeOther, + Message: "tool calls were returned but the turn ended abnormally (token limit or content filter); arguments may be truncated", + }) + } else { + for _, tc := range pendingFunctionCalls { + content = append(content, tc) + } + } + + metadata := responsesProviderMetadata(response.ID) + o.applyHeaders(capture.header(), &metadata) return &fantasy.Response{ Content: content, Usage: usage, FinishReason: finishReason, - ProviderMetadata: responsesProviderMetadata(response.ID), + ProviderMetadata: metadata, Warnings: warnings, }, nil } func mapResponsesFinishReason(reason string, hasFunctionCall bool) fantasy.FinishReason { - if hasFunctionCall { - return fantasy.FinishReasonToolCalls - } - + // A terminal reason the upstream actually sent always wins. Rewriting one + // into a tool-call turn hides a turn that was cut short, and the agent + // then validates, repairs and dispatches truncated arguments + // (CHARM-2020). The chat-completions adapter keeps length, content_filter + // and error for the same reason; only a reason the switch does not + // recognise falls back to the tool-call inference. switch reason { case "": + if hasFunctionCall { + return fantasy.FinishReasonToolCalls + } return fantasy.FinishReasonStop case "max_tokens", "max_output_tokens": return fantasy.FinishReasonLength case "content_filter": return fantasy.FinishReasonContentFilter default: + if hasFunctionCall { + return fantasy.FinishReasonToolCalls + } return fantasy.FinishReasonOther } } +// abnormalFinishReason reports whether a Responses turn ended abnormally. Such +// a turn can carry function-call arguments that were cut short mid-serialization, +// so the calls must not be dispatched (CHARM-2020). +func abnormalFinishReason(reason fantasy.FinishReason) bool { + switch reason { + case fantasy.FinishReasonLength, fantasy.FinishReasonContentFilter: + return true + default: + return false + } +} + func (o responsesLanguageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.StreamResponse, error) { + capture := responseCapture{} params, warnings, err := o.prepareParams(call) if err != nil { return nil, err } - stream := o.client.Responses.NewStreaming(ctx, *params, append(callUARequestOptions(call), callHeadersRequestOptions(call)...)...) + stream := o.client.Responses.NewStreaming(ctx, *params, capture.requestOptions(o.headerFunc, append(callUARequestOptions(call), callHeadersRequestOptions(call)...))...) finishReason := fantasy.FinishReasonUnknown var usage fantasy.Usage @@ -1614,11 +1688,23 @@ func (o responsesLanguageModel) Stream(ctx context.Context, call fantasy.Call) ( return } + if hasFunctionCall && abnormalFinishReason(finishReason) { + yield(fantasy.StreamPart{ + Type: fantasy.StreamPartTypeWarnings, + Warnings: []fantasy.CallWarning{{ + Type: fantasy.CallWarningTypeOther, + Message: "tool calls were returned but the turn ended abnormally (token limit or content filter); arguments may be truncated", + }}, + }) + } + + metadata := responsesProviderMetadata(responseID) + o.applyHeaders(capture.header(), &metadata) yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeFinish, Usage: usage, FinishReason: finishReason, - ProviderMetadata: responsesProviderMetadata(responseID), + ProviderMetadata: metadata, }) }, nil } @@ -1787,7 +1873,8 @@ func (o responsesLanguageModel) generateObjectWithJSONMode(ctx context.Context, } // Make request - response, err := o.client.Responses.New(ctx, *params, append(objectCallUARequestOptions(call), objectCallHeadersRequestOptions(call)...)...) + capture := responseCapture{} + response, err := o.client.Responses.New(ctx, *params, capture.requestOptions(o.headerFunc, append(objectCallUARequestOptions(call), objectCallHeadersRequestOptions(call)...))...) if err != nil { return nil, toProviderErr(err) } @@ -1813,11 +1900,7 @@ func (o responsesLanguageModel) generateObjectWithJSONMode(ctx context.Context, } if jsonText == "" { - usage := fantasy.Usage{ - InputTokens: response.Usage.InputTokens, - OutputTokens: response.Usage.OutputTokens, - TotalTokens: response.Usage.InputTokens + response.Usage.OutputTokens, - } + usage := responsesUsage(*response) finishReason := mapResponsesFinishReason(response.IncompleteDetails.Reason, false) return nil, &fantasy.NoObjectGeneratedError{ RawText: "", @@ -1847,13 +1930,16 @@ func (o responsesLanguageModel) generateObjectWithJSONMode(ctx context.Context, return nil, err } + metadata := responsesProviderMetadata(response.ID) + o.applyHeaders(capture.header(), &metadata) + return &fantasy.ObjectResponse{ Object: obj, RawText: jsonText, Usage: usage, FinishReason: finishReason, Warnings: warnings, - ProviderMetadata: responsesProviderMetadata(response.ID), + ProviderMetadata: metadata, }, nil } @@ -1890,7 +1976,8 @@ func (o responsesLanguageModel) streamObjectWithJSONMode(ctx context.Context, ca Format: responses.ResponseFormatTextConfigParamOfJSONSchema(schemaName, jsonSchemaMap), } - stream := o.client.Responses.NewStreaming(ctx, *params, append(objectCallUARequestOptions(call), objectCallHeadersRequestOptions(call)...)...) + capture := responseCapture{} + stream := o.client.Responses.NewStreaming(ctx, *params, capture.requestOptions(o.headerFunc, append(objectCallUARequestOptions(call), objectCallHeadersRequestOptions(call)...))...) return func(yield func(fantasy.ObjectStreamPart) bool) { if len(warnings) > 0 { @@ -2024,11 +2111,13 @@ func (o responsesLanguageModel) streamObjectWithJSONMode(ctx context.Context, ca // Final validation and emit if lastParsedObject != nil { + metadata := responsesProviderMetadata(responseID) + o.applyHeaders(capture.header(), &metadata) yield(fantasy.ObjectStreamPart{ Type: fantasy.ObjectStreamPartTypeFinish, Usage: usage, FinishReason: finishReason, - ProviderMetadata: responsesProviderMetadata(responseID), + ProviderMetadata: metadata, }) } else { // No object was generated diff --git a/providers/openai/responses_options.go b/providers/openai/responses_options.go index c5192f968..3cd7875fb 100644 --- a/providers/openai/responses_options.go +++ b/providers/openai/responses_options.go @@ -76,6 +76,22 @@ func init() { // The ResponseID can be used as PreviousResponseID in follow-up requests to chain responses. type ResponsesProviderMetadata struct { ResponseID string `json:"response_id"` + // ExtraFields holds non-standard response fields, including any + // captured via [LanguageModelHeaderFunc]. + ExtraFields map[string]json.RawMessage `json:"extra_fields,omitempty"` +} + +// ExtraField unmarshals the extra field with the given key, if present, +// into target. +func (m *ResponsesProviderMetadata) ExtraField(key string, target any) bool { + if m == nil || m.ExtraFields == nil { + return false + } + raw, ok := m.ExtraFields[key] + if !ok { + return false + } + return json.Unmarshal(raw, target) == nil } var _ fantasy.ProviderOptionsData = (*ResponsesProviderMetadata)(nil) diff --git a/providers/openai/tool_messages.go b/providers/openai/tool_messages.go new file mode 100644 index 000000000..9d3abf178 --- /dev/null +++ b/providers/openai/tool_messages.go @@ -0,0 +1,203 @@ +package openai + +import ( + "fmt" + "strings" + + "github.com/openai/openai-go/v3" + + "charm.land/fantasy" +) + +// ToolCacheTypeFunc returns the prompt-cache hint to attach to the tool result +// at index i of msg, or "" for none. Providers without prompt caching pass nil. +// +// See anthropic.ToolResultCacheType for the Anthropic-style implementation. +type ToolCacheTypeFunc func(msg fantasy.Message, i int) string + +// ToolMessages converts a tool-role prompt message into the chat-completions +// messages answering the assistant's tool_calls. +// +// Media results come back in deferred rather than messages: they convert to a +// user message, which cannot sit inside the run of tool messages. Hand them to +// a ToolRunBuffer. +func ToolMessages(msg fantasy.Message, cacheType ToolCacheTypeFunc) (messages, deferred []openai.ChatCompletionMessageParamUnion, warnings []fantasy.CallWarning) { + warn := func(format string, args ...any) { + warnings = append(warnings, fantasy.CallWarning{ + Type: fantasy.CallWarningTypeOther, + Message: fmt.Sprintf(format, args...), + }) + } + + for i, c := range msg.Content { + if c.GetType() != fantasy.ContentTypeToolResult { + warn("tool message can only have tool result content") + continue + } + part, ok := fantasy.AsContentType[fantasy.ToolResultPart](c) + if !ok { + warn("tool message result part does not have the right type") + continue + } + + var hint string + if cacheType != nil { + hint = cacheType(msg, i) + } + + switch part.Output.GetType() { + case fantasy.ToolResultContentTypeText: + output, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentText](part.Output) + if !ok { + warn("tool result output does not have the right type") + continue + } + messages = append(messages, textToolMessage(output.Text, part.ToolCallID, hint)) + case fantasy.ToolResultContentTypeError: + output, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentError](part.Output) + if !ok { + warn("tool result output does not have the right type") + continue + } + messages = append(messages, textToolMessage(output.Error.Error(), part.ToolCallID, hint)) + case fantasy.ToolResultContentTypeMedia: + output, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentMedia](part.Output) + if !ok { + warn("tool result output does not have the right type") + continue + } + tool, media, mediaWarnings := ToolResultMediaMessages(output, part.ToolCallID) + tagToolCacheControl(tool.OfTool, hint) + messages = append(messages, tool) + deferred = append(deferred, media...) + warnings = append(warnings, mediaWarnings...) + default: + // Skipping silently would leave the assistant's tool_call + // unanswered, which strict backends reject. + warn("tool result output type %q not supported", part.Output.GetType()) + } + } + return messages, deferred, warnings +} + +// textToolMessage builds a tool message, tagged for prompt caching when the +// provider asked for a hint. +func textToolMessage(text, toolCallID, cacheType string) openai.ChatCompletionMessageParamUnion { + msg := openai.ToolMessage(text, toolCallID) + tagToolCacheControl(msg.OfTool, cacheType) + return msg +} + +// tagToolCacheControl marks a tool message for Anthropic-style prompt caching. +// +// It takes the tool param rather than the message union that wraps it: the +// union is not the value that gets marshalled, so setting the extra field +// there compiles and silently drops the hint. +func tagToolCacheControl(msg *openai.ChatCompletionToolMessageParam, cacheType string) { + if msg == nil || cacheType == "" { + return + } + msg.SetExtraFields(map[string]any{ + "cache_control": map[string]string{"type": cacheType}, + }) +} + +// ToolResultMediaMessages maps a tool-result media output to the chat +// completions messages that convey it: a text tool message that keeps the +// tool_call/tool_result pairing valid, plus the user messages holding the image +// or audio itself, which a tool message cannot carry. +// +// The two are returned separately because the tool message must stay inside the +// run of tool messages while the media has to land after it. Unsupported media +// types return only the tool message, plus a warning. +func ToolResultMediaMessages(output fantasy.ToolResultOutputContentMedia, toolCallID string) (openai.ChatCompletionMessageParamUnion, []openai.ChatCompletionMessageParamUnion, []fantasy.CallWarning) { + mediaPart, warning, emit := toolResultMediaUserPart(output) + + placeholder := output.Text + if placeholder == "" && emit { + placeholder = fmt.Sprintf("The tool returned %s content; see the following user message.", output.MediaType) + } else if placeholder == "" { + placeholder = fmt.Sprintf("The tool returned %s content, which cannot be displayed.", output.MediaType) + } + toolMessage := openai.ToolMessage(placeholder, toolCallID) + + if warning != nil { + return toolMessage, nil, []fantasy.CallWarning{*warning} + } + if !emit { + return toolMessage, nil, nil + } + return toolMessage, []openai.ChatCompletionMessageParamUnion{ + openai.UserMessage([]openai.ChatCompletionContentPartUnionParam{mediaPart}), + }, nil +} + +// toolResultMediaUserPart maps a tool-result media output to a user content +// part, reporting whether the caller should emit it. +func toolResultMediaUserPart(output fantasy.ToolResultOutputContentMedia) (openai.ChatCompletionContentPartUnionParam, *fantasy.CallWarning, bool) { + switch { + case strings.HasPrefix(output.MediaType, "image/"): + data := "data:" + output.MediaType + ";base64," + output.Data + imageBlock := openai.ChatCompletionContentPartImageParam{ + ImageURL: openai.ChatCompletionContentPartImageImageURLParam{URL: data}, + } + return openai.ChatCompletionContentPartUnionParam{OfImageURL: &imageBlock}, nil, true + case output.MediaType == "audio/wav", output.MediaType == "audio/mpeg", output.MediaType == "audio/mp3": + format := "wav" + if output.MediaType != "audio/wav" { + format = "mp3" + } + audioBlock := openai.ChatCompletionContentPartInputAudioParam{ + InputAudio: openai.ChatCompletionContentPartInputAudioInputAudioParam{ + Data: output.Data, + Format: format, + }, + } + return openai.ChatCompletionContentPartUnionParam{OfInputAudio: &audioBlock}, nil, true + default: + return openai.ChatCompletionContentPartUnionParam{}, &fantasy.CallWarning{ + Type: fantasy.CallWarningTypeOther, + Message: fmt.Sprintf("tool result media type %s not supported, sending text placeholder only", output.MediaType), + }, false + } +} + +// ToolRunBuffer holds synthetic user messages carrying tool-result media back +// until the current run of tool messages ends. +// +// Chat-completions validators require every tool message answering an +// assistant's tool_calls to follow that assistant message with only other tool +// messages in between, so emitting media where it is produced splits the run +// and every tool_call_id after the split reads as unanswered. +type ToolRunBuffer struct { + deferred []openai.ChatCompletionMessageParamUnion +} + +// Role reports that a prompt message of the given role is about to be +// converted, flushing any deferred media when that role ends a tool run. +// It returns messages with the flush applied. +func (b *ToolRunBuffer) Role(role fantasy.MessageRole, messages []openai.ChatCompletionMessageParamUnion) []openai.ChatCompletionMessageParamUnion { + if role == fantasy.MessageRoleTool { + return messages + } + return b.flush(messages) +} + +// Defer holds media messages until the current tool run ends. +func (b *ToolRunBuffer) Defer(messages ...openai.ChatCompletionMessageParamUnion) { + b.deferred = append(b.deferred, messages...) +} + +// Close flushes anything still deferred when the prompt ends on a tool run. +func (b *ToolRunBuffer) Close(messages []openai.ChatCompletionMessageParamUnion) []openai.ChatCompletionMessageParamUnion { + return b.flush(messages) +} + +func (b *ToolRunBuffer) flush(messages []openai.ChatCompletionMessageParamUnion) []openai.ChatCompletionMessageParamUnion { + if len(b.deferred) == 0 { + return messages + } + messages = append(messages, b.deferred...) + b.deferred = nil + return messages +} diff --git a/providers/openai/tool_result_media_test.go b/providers/openai/tool_result_media_test.go index 883494774..aa86cbd67 100644 --- a/providers/openai/tool_result_media_test.go +++ b/providers/openai/tool_result_media_test.go @@ -165,6 +165,144 @@ func TestDefaultToPrompt_MediaToolResult_UnsupportedMediaType(t *testing.T) { require.Contains(t, warnings[0].Message, "video/mp4") } +// The ordering tests below all exercise the same invariant: the run of tool +// messages answering an assistant's tool_calls must stay contiguous, so the +// synthetic user messages carrying media land after the run ends. + +func mediaToolResult(id string) fantasy.MessagePart { + return fantasy.ToolResultPart{ + ToolCallID: id, + Output: fantasy.ToolResultOutputContentMedia{ + Data: base64.StdEncoding.EncodeToString([]byte{1, 2, 3}), + MediaType: "image/png", + }, + } +} + +func textToolResult(id string) fantasy.MessagePart { + return fantasy.ToolResultPart{ + ToolCallID: id, + Output: fantasy.ToolResultOutputContentText{Text: "ok"}, + } +} + +// A parallel tool-call batch with media results must keep its tool +// messages contiguous: strict validators report every tool_call_id after +// a mid-run user message as unanswered. +func TestDefaultToPrompt_MediaToolResult_ParallelBatchDeferred(t *testing.T) { + t.Parallel() + + prompt := fantasy.Prompt{ + { + Role: fantasy.MessageRoleAssistant, + Content: []fantasy.MessagePart{ + fantasy.ToolCallPart{ToolCallID: "img-1", ToolName: "view", Input: "{}"}, + fantasy.ToolCallPart{ToolCallID: "img-2", ToolName: "view", Input: "{}"}, + }, + }, + {Role: fantasy.MessageRoleTool, Content: []fantasy.MessagePart{mediaToolResult("img-1")}}, + {Role: fantasy.MessageRoleTool, Content: []fantasy.MessagePart{mediaToolResult("img-2")}}, + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{fantasy.TextPart{Text: "next"}}}, + } + + messages, warnings := DefaultToPrompt(prompt, "openai", "gpt-4o") + + require.Empty(t, warnings) + // Assistant, two tool messages, two deferred user image messages, user. + require.Len(t, messages, 6) + require.NotNil(t, messages[1].OfTool) + require.Equal(t, "img-1", messages[1].OfTool.ToolCallID) + require.NotNil(t, messages[2].OfTool) + require.Equal(t, "img-2", messages[2].OfTool.ToolCallID) + require.NotNil(t, messages[3].OfUser) + require.NotNil(t, messages[3].OfUser.Content.OfArrayOfContentParts[0].OfImageURL) + require.NotNil(t, messages[4].OfUser) + require.NotNil(t, messages[4].OfUser.Content.OfArrayOfContentParts[0].OfImageURL) + require.NotNil(t, messages[5].OfUser) +} + +// When the tool run ends the prompt, the deferred media messages flush at +// the end rather than being dropped. +func TestDefaultToPrompt_MediaToolResult_DeferredFlushAtEnd(t *testing.T) { + t.Parallel() + + prompt := fantasy.Prompt{ + { + Role: fantasy.MessageRoleAssistant, + Content: []fantasy.MessagePart{ + fantasy.ToolCallPart{ToolCallID: "img-1", ToolName: "view", Input: "{}"}, + }, + }, + {Role: fantasy.MessageRoleTool, Content: []fantasy.MessagePart{mediaToolResult("img-1")}}, + } + + messages, warnings := DefaultToPrompt(prompt, "openai", "gpt-4o") + + require.Empty(t, warnings) + require.Len(t, messages, 3) + require.NotNil(t, messages[1].OfTool) + require.NotNil(t, messages[2].OfUser) + require.NotNil(t, messages[2].OfUser.Content.OfArrayOfContentParts[0].OfImageURL) +} + +// Only the media user messages are deferred. A text tool result later in the +// same batch still has to be emitted in place, right after the media result's +// own tool message. +func TestDefaultToPrompt_MediaToolResult_MixedBatchKeepsTextInline(t *testing.T) { + t.Parallel() + + prompt := fantasy.Prompt{ + { + Role: fantasy.MessageRoleAssistant, + Content: []fantasy.MessagePart{ + fantasy.ToolCallPart{ToolCallID: "img-1", ToolName: "view", Input: "{}"}, + fantasy.ToolCallPart{ToolCallID: "txt-1", ToolName: "grep", Input: "{}"}, + }, + }, + {Role: fantasy.MessageRoleTool, Content: []fantasy.MessagePart{mediaToolResult("img-1")}}, + {Role: fantasy.MessageRoleTool, Content: []fantasy.MessagePart{textToolResult("txt-1")}}, + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{fantasy.TextPart{Text: "next"}}}, + } + + messages, warnings := DefaultToPrompt(prompt, "openai", "gpt-4o") + + require.Empty(t, warnings) + // Assistant, both tool messages, the deferred user image message, user. + require.Len(t, messages, 5) + require.NotNil(t, messages[1].OfTool) + require.Equal(t, "img-1", messages[1].OfTool.ToolCallID) + require.NotNil(t, messages[2].OfTool) + require.Equal(t, "txt-1", messages[2].OfTool.ToolCallID) + require.NotNil(t, messages[3].OfUser) + require.NotNil(t, messages[3].OfUser.Content.OfArrayOfContentParts[0].OfImageURL) + require.NotNil(t, messages[4].OfUser) +} + +// The run also ends at an assistant message, not just a user one. +func TestDefaultToPrompt_MediaToolResult_DeferredBeforeAssistant(t *testing.T) { + t.Parallel() + + prompt := fantasy.Prompt{ + { + Role: fantasy.MessageRoleAssistant, + Content: []fantasy.MessagePart{ + fantasy.ToolCallPart{ToolCallID: "img-1", ToolName: "view", Input: "{}"}, + }, + }, + {Role: fantasy.MessageRoleTool, Content: []fantasy.MessagePart{mediaToolResult("img-1")}}, + {Role: fantasy.MessageRoleAssistant, Content: []fantasy.MessagePart{fantasy.TextPart{Text: "done"}}}, + } + + messages, warnings := DefaultToPrompt(prompt, "openai", "gpt-4o") + + require.Empty(t, warnings) + require.Len(t, messages, 4) + require.NotNil(t, messages[1].OfTool) + require.NotNil(t, messages[2].OfUser) + require.NotNil(t, messages[2].OfUser.Content.OfArrayOfContentParts[0].OfImageURL) + require.NotNil(t, messages[3].OfAssistant) +} + func TestToResponsesPrompt_MediaToolResult_ImagePNG(t *testing.T) { t.Parallel() diff --git a/providers/openai/tool_run_buffer_test.go b/providers/openai/tool_run_buffer_test.go new file mode 100644 index 000000000..7b847bdfc --- /dev/null +++ b/providers/openai/tool_run_buffer_test.go @@ -0,0 +1,194 @@ +package openai + +import ( + "testing" + + "github.com/openai/openai-go/v3" + "github.com/stretchr/testify/require" + + "charm.land/fantasy" +) + +// ToolRunBuffer owns one rule: a run of tool messages answering an assistant's +// tool_calls must stay contiguous, so synthetic media user messages land after +// the run ends. All four chat-completions converters route through it, which +// is why it is tested here rather than four times over. + +// label renders a message as a short role tag so the tests can assert on +// ordering without caring about content. +func label(m openai.ChatCompletionMessageParamUnion) string { + switch { + case m.OfTool != nil: + return "tool:" + m.OfTool.ToolCallID + case m.OfAssistant != nil: + return "assistant" + case m.OfUser != nil: + return "user" + default: + return "other" + } +} + +func labels(msgs []openai.ChatCompletionMessageParamUnion) []string { + out := make([]string, 0, len(msgs)) + for _, m := range msgs { + out = append(out, label(m)) + } + return out +} + +// step is one prompt message being converted: its role, what the converter +// emits inline, and what it defers. +type step struct { + role fantasy.MessageRole + emit []openai.ChatCompletionMessageParamUnion + deferred []openai.ChatCompletionMessageParamUnion +} + +// run drives the buffer the way every converter's loop does. +func run(steps []step) []openai.ChatCompletionMessageParamUnion { + var buf ToolRunBuffer + var messages []openai.ChatCompletionMessageParamUnion + for _, s := range steps { + messages = buf.Role(s.role, messages) + messages = append(messages, s.emit...) + buf.Defer(s.deferred...) + } + return buf.Close(messages) +} + +func assistant(text string) []openai.ChatCompletionMessageParamUnion { + return []openai.ChatCompletionMessageParamUnion{openai.AssistantMessage(text)} +} + +func tool(id string) []openai.ChatCompletionMessageParamUnion { + return []openai.ChatCompletionMessageParamUnion{openai.ToolMessage("result", id)} +} + +func user(text string) []openai.ChatCompletionMessageParamUnion { + return []openai.ChatCompletionMessageParamUnion{openai.UserMessage(text)} +} + +func TestToolRunBuffer(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + steps []step + want []string + }{ + { + // Nothing deferred: the buffer must not reorder anything. + name: "passthrough", + steps: []step{ + {role: fantasy.MessageRoleUser, emit: user("hi")}, + {role: fantasy.MessageRoleAssistant, emit: assistant("hello")}, + }, + want: []string{"user", "assistant"}, + }, + { + // A parallel batch: both tool messages stay contiguous and the + // media lands after the run, before the next user turn. + name: "parallel batch keeps tool run contiguous", + steps: []step{ + {role: fantasy.MessageRoleAssistant, emit: assistant("on it")}, + {role: fantasy.MessageRoleTool, emit: tool("c1"), deferred: user("media1")}, + {role: fantasy.MessageRoleTool, emit: tool("c2"), deferred: user("media2")}, + {role: fantasy.MessageRoleUser, emit: user("next")}, + }, + want: []string{"assistant", "tool:c1", "tool:c2", "user", "user", "user"}, + }, + { + // A text result in the same batch still emits in place. + name: "mixed batch keeps text inline", + steps: []step{ + {role: fantasy.MessageRoleAssistant, emit: assistant("on it")}, + {role: fantasy.MessageRoleTool, emit: tool("img"), deferred: user("media")}, + {role: fantasy.MessageRoleTool, emit: tool("txt")}, + {role: fantasy.MessageRoleUser, emit: user("next")}, + }, + want: []string{"assistant", "tool:img", "tool:txt", "user", "user"}, + }, + { + // The run ends at an assistant message too, not just a user one. + name: "flush before assistant", + steps: []step{ + {role: fantasy.MessageRoleAssistant, emit: assistant("on it")}, + {role: fantasy.MessageRoleTool, emit: tool("c1"), deferred: user("media")}, + {role: fantasy.MessageRoleAssistant, emit: assistant("done")}, + }, + want: []string{"assistant", "tool:c1", "user", "assistant"}, + }, + { + // When the prompt ends on a tool run, Close flushes rather than + // dropping the media. + name: "flush at end", + steps: []step{ + {role: fantasy.MessageRoleAssistant, emit: assistant("on it")}, + {role: fantasy.MessageRoleTool, emit: tool("c1"), deferred: user("media")}, + }, + want: []string{"assistant", "tool:c1", "user"}, + }, + { + // Two assistant turns. The second turn's tool message must not be + // hoisted ahead of the first turn's trailing messages: this is the + // case a blind post-pass over the finished slice gets wrong. + name: "two turns keep their own runs", + steps: []step{ + {role: fantasy.MessageRoleAssistant, emit: assistant("first")}, + {role: fantasy.MessageRoleTool, emit: tool("c1"), deferred: user("media")}, + {role: fantasy.MessageRoleAssistant, emit: assistant("second")}, + {role: fantasy.MessageRoleTool, emit: tool("c2")}, + {role: fantasy.MessageRoleUser, emit: user("final")}, + }, + want: []string{"assistant", "tool:c1", "user", "assistant", "tool:c2", "user"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.want, labels(run(tt.steps))) + }) + } +} + +// Close is the flush every converter ends on, so it has to be idempotent: a +// second call must not replay the media it already emitted. +func TestToolRunBufferCloseIsIdempotent(t *testing.T) { + t.Parallel() + + var buf ToolRunBuffer + var messages []openai.ChatCompletionMessageParamUnion + messages = append(messages, tool("c1")...) + buf.Defer(user("media")...) + + messages = buf.Close(messages) + require.Equal(t, []string{"tool:c1", "user"}, labels(messages)) + require.Equal(t, []string{"tool:c1", "user"}, labels(buf.Close(messages))) +} + +// Role flushes on a non-tool role, so calling it twice for the same run must +// not replay either. +func TestToolRunBufferRoleFlushesOnce(t *testing.T) { + t.Parallel() + + var buf ToolRunBuffer + buf.Defer(user("media")...) + + messages := buf.Role(fantasy.MessageRoleUser, nil) + require.Equal(t, []string{"user"}, labels(messages)) + require.Equal(t, []string{"user"}, labels(buf.Role(fantasy.MessageRoleUser, messages))) + require.Equal(t, []string{"user"}, labels(buf.Close(messages))) +} + +// A zero buffer that never defers anything must leave the slice untouched, +// including its nil case. +func TestToolRunBufferZeroValue(t *testing.T) { + t.Parallel() + + var buf ToolRunBuffer + require.Empty(t, buf.Close(nil)) + require.Empty(t, buf.Role(fantasy.MessageRoleTool, nil)) + require.Equal(t, []string{"tool:c1"}, labels(buf.Close(tool("c1")))) +} diff --git a/providers/openaicompat/header_test.go b/providers/openaicompat/header_test.go new file mode 100644 index 000000000..f10da5daa --- /dev/null +++ b/providers/openaicompat/header_test.go @@ -0,0 +1,343 @@ +package openaicompat + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "charm.land/fantasy" + "charm.land/fantasy/providers/openai" + "github.com/stretchr/testify/require" +) + +// prismHeaderFunc copies the Hyper Prism router headers and trailers into +// the provider metadata extra fields, mirroring how Crush consumes them. +func prismHeaderFunc(header http.Header, metadata *openai.ProviderMetadata) { + for key, field := range map[string]string{ + "X-Prism-Model-Id": "x-prism-model-id", + "X-Prism-Model-Name": "x-prism-model-name", + "X-Prism-Hypercredit-Savings": "x-prism-hypercredit-savings", + } { + if v := header.Get(key); v != "" { + if metadata.ExtraFields == nil { + metadata.ExtraFields = make(map[string]json.RawMessage) + } + metadata.ExtraFields[field] = json.RawMessage(strconv.Quote(v)) + } + } +} + +// servePrism answers both streaming and non-streaming chat completion +// requests with canned payloads carrying the Prism router headers. +func servePrism(t *testing.T) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Prism-Model-Id", "prism-42") + w.Header().Set("X-Prism-Model-Name", "GPT-5.2 Codex Max") + var reqShape struct { + Stream bool `json:"stream"` + } + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &reqShape) + if reqShape.Stream { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"id\":\"z\",\"created\":1,\"model\":\"prism\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"Hi\"},\"finish_reason\":null}]}\n\n")) + _, _ = w.Write([]byte("data: {\"id\":\"z\",\"created\":1,\"model\":\"prism\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n")) + _, _ = w.Write([]byte("data: {\"id\":\"z\",\"created\":1,\"model\":\"prism\",\"choices\":[],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1,\"total_tokens\":2}}\n\n")) + _, _ = w.Write([]byte("data: [DONE]\n\n")) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"z","created":1,"model":"prism","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"Hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`)) + })) + t.Cleanup(srv.Close) + return srv +} + +func prompt() fantasy.Prompt { + return fantasy.Prompt{ + { + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{fantasy.TextPart{Text: "Hello"}}, + }, + } +} + +func prismProvider(t *testing.T, srv *httptest.Server) fantasy.Provider { + t.Helper() + provider, err := New( + WithBaseURL(srv.URL), + WithAPIKey("test"), + WithLanguageModelOptions(openai.WithLanguageModelHeaderFunc(prismHeaderFunc)), + ) + require.NoError(t, err) + return provider +} + +func requirePrismFields(t *testing.T, metadata fantasy.ProviderMetadata) { + t.Helper() + data, ok := metadata[openai.Name] + require.True(t, ok, "expected openai provider metadata") + providerMetadata, ok := data.(*openai.ProviderMetadata) + require.True(t, ok, "expected *openai.ProviderMetadata") + require.NotNil(t, providerMetadata.ExtraFields) + + var modelID string + ok = providerMetadata.ExtraField("x-prism-model-id", &modelID) + require.True(t, ok) + require.Equal(t, "prism-42", modelID) + + var modelName string + ok = providerMetadata.ExtraField("x-prism-model-name", &modelName) + require.True(t, ok) + require.Equal(t, "GPT-5.2 Codex Max", modelName) +} + +func TestResponseHeaders_Generate(t *testing.T) { + t.Parallel() + srv := servePrism(t) + provider := prismProvider(t, srv) + lm, err := provider.LanguageModel(context.Background(), "prism") + require.NoError(t, err) + + response, err := lm.Generate(context.Background(), fantasy.Call{Prompt: prompt()}) + require.NoError(t, err) + requirePrismFields(t, response.ProviderMetadata) +} + +func TestResponseHeaders_Stream(t *testing.T) { + t.Parallel() + srv := servePrism(t) + provider := prismProvider(t, srv) + lm, err := provider.LanguageModel(context.Background(), "prism") + require.NoError(t, err) + + stream, err := lm.Stream(context.Background(), fantasy.Call{Prompt: prompt()}) + require.NoError(t, err) + var finish *fantasy.StreamPart + for part := range stream { + if part.Type == fantasy.StreamPartTypeFinish { + finish = &part + } + } + require.NotNil(t, finish, "expected a finish part") + requirePrismFields(t, finish.ProviderMetadata) +} + +func TestResponseHeaders_StreamWithoutUsage(t *testing.T) { + t.Parallel() + // A stream whose chunks carry no usage leaves the stream metadata nil + // after the loop; the header func must still surface the headers. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Prism-Model-Id", "prism-42") + w.Header().Set("X-Prism-Model-Name", "GPT-5.2 Codex Max") + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"id\":\"z\",\"created\":1,\"model\":\"prism\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"Hi\"},\"finish_reason\":null}]}\n\n")) + _, _ = w.Write([]byte("data: {\"id\":\"z\",\"created\":1,\"model\":\"prism\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n")) + _, _ = w.Write([]byte("data: [DONE]\n\n")) + })) + t.Cleanup(srv.Close) + provider := prismProvider(t, srv) + lm, err := provider.LanguageModel(context.Background(), "prism") + require.NoError(t, err) + + stream, err := lm.Stream(context.Background(), fantasy.Call{Prompt: prompt()}) + require.NoError(t, err) + var finish *fantasy.StreamPart + for part := range stream { + if part.Type == fantasy.StreamPartTypeFinish { + finish = &part + } + } + require.NotNil(t, finish) + require.Zero(t, finish.Usage.TotalTokens) + requirePrismFields(t, finish.ProviderMetadata) +} + +// serveResponses answers Responses API requests (streaming and +// non-streaming) with canned payloads carrying the Prism router headers. +func serveResponses(t *testing.T) *httptest.Server { + t.Helper() + const responseBody = `{"id":"resp_1","object":"response","created_at":1,"status":"completed","model":"gpt-5","output":[{"id":"msg_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"Hi","annotations":[]}]}],"usage":{"input_tokens":1,"output_tokens":1,"output_tokens_details":{},"total_tokens":2}}` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Prism-Model-Id", "prism-42") + w.Header().Set("X-Prism-Model-Name", "GPT-5.2 Codex Max") + var reqShape struct { + Stream bool `json:"stream"` + } + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &reqShape) + if reqShape.Stream { + w.Header().Set("Content-Type", "text/event-stream") + rc := http.NewResponseController(w) + events := []string{ + `{"type":"response.created","response":` + responseBody + `}`, + `{"type":"response.output_item.added","output_index":0,"item":{"id":"msg_1","type":"message","role":"assistant","status":"in_progress","content":[]}}`, + `{"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"content_index":0,"delta":"Hi"}`, + `{"type":"response.completed","response":` + responseBody + `}`, + } + for _, event := range events { + fmt.Fprintf(w, "event: %s\ndata: %s\n\n", strings.SplitN(event, ":", 2)[0][2:], event) + _ = rc.Flush() + } + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(responseBody)) + })) + t.Cleanup(srv.Close) + return srv +} + +func prismResponsesProvider(t *testing.T, srv *httptest.Server) fantasy.Provider { + t.Helper() + provider, err := New( + WithBaseURL(srv.URL), + WithAPIKey("test"), + WithUseResponsesAPI(), + WithLanguageModelOptions(openai.WithLanguageModelHeaderFunc(prismHeaderFunc)), + ) + require.NoError(t, err) + return provider +} + +func requirePrismResponsesFields(t *testing.T, metadata fantasy.ProviderMetadata) { + t.Helper() + data, ok := metadata[openai.Name] + require.True(t, ok, "expected openai provider metadata") + responsesMetadata, ok := data.(*openai.ResponsesProviderMetadata) + require.True(t, ok, "expected *openai.ResponsesProviderMetadata, got %T", data) + require.NotNil(t, responsesMetadata.ExtraFields) + + var modelID string + ok = responsesMetadata.ExtraField("x-prism-model-id", &modelID) + require.True(t, ok) + require.Equal(t, "prism-42", modelID) + + var modelName string + ok = responsesMetadata.ExtraField("x-prism-model-name", &modelName) + require.True(t, ok) + require.Equal(t, "GPT-5.2 Codex Max", modelName) +} + +func TestResponseHeaders_ResponsesGenerate(t *testing.T) { + t.Parallel() + srv := serveResponses(t) + provider := prismResponsesProvider(t, srv) + lm, err := provider.LanguageModel(context.Background(), "gpt-5") + require.NoError(t, err) + + response, err := lm.Generate(context.Background(), fantasy.Call{Prompt: prompt()}) + require.NoError(t, err) + requirePrismResponsesFields(t, response.ProviderMetadata) +} + +func TestResponseHeaders_ResponsesStream(t *testing.T) { + t.Parallel() + srv := serveResponses(t) + provider := prismResponsesProvider(t, srv) + lm, err := provider.LanguageModel(context.Background(), "gpt-5") + require.NoError(t, err) + + stream, err := lm.Stream(context.Background(), fantasy.Call{Prompt: prompt()}) + require.NoError(t, err) + var finish *fantasy.StreamPart + for part := range stream { + if part.Type == fantasy.StreamPartTypeFinish { + finish = &part + } + } + require.NotNil(t, finish, "expected a finish part") + requirePrismResponsesFields(t, finish.ProviderMetadata) +} + +// servePrismTrailers answers chat completion requests carrying the Prism +// savings as declared HTTP trailers, which net/http populates on the +// client only after the response body is fully consumed. +func servePrismTrailers(t *testing.T) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var reqShape struct { + Stream bool `json:"stream"` + } + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &reqShape) + w.Header().Set("Trailer", "X-Prism-Hypercredit-Savings") + if reqShape.Stream { + w.Header().Set("Content-Type", "text/event-stream") + rc := http.NewResponseController(w) + _, _ = w.Write([]byte("data: {\"id\":\"z\",\"created\":1,\"model\":\"prism\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"Hi\"},\"finish_reason\":null}]}\n\n")) + _ = rc.Flush() + _, _ = w.Write([]byte("data: {\"id\":\"z\",\"created\":1,\"model\":\"prism\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n")) + _ = rc.Flush() + _, _ = w.Write([]byte("data: {\"id\":\"z\",\"created\":1,\"model\":\"prism\",\"choices\":[],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1,\"total_tokens\":2}}\n\n")) + _ = rc.Flush() + _, _ = w.Write([]byte("data: [DONE]\n\n")) + _ = rc.Flush() + // The savings trailer is written only after usage is final, which + // on a real server happens after the DONE sentinel has been sent: + // pricing and stats run in between. Give the client time to observe + // DONE first, so the test reproduces a trailer that arrives after + // the stream's terminal event. + time.Sleep(100 * time.Millisecond) + w.Header().Set("X-Prism-Hypercredit-Savings", "1.5") + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"z","created":1,"model":"prism","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"Hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`)) + w.Header().Set("X-Prism-Hypercredit-Savings", "1.5") + })) + t.Cleanup(srv.Close) + return srv +} + +func requirePrismTrailerField(t *testing.T, metadata fantasy.ProviderMetadata) { + t.Helper() + data, ok := metadata[openai.Name] + require.True(t, ok, "expected openai provider metadata") + providerMetadata, ok := data.(*openai.ProviderMetadata) + require.True(t, ok, "expected *openai.ProviderMetadata") + + var savings string + ok = providerMetadata.ExtraField("x-prism-hypercredit-savings", &savings) + require.True(t, ok) + require.Equal(t, "1.5", savings) +} + +func TestResponseTrailers_Generate(t *testing.T) { + t.Parallel() + srv := servePrismTrailers(t) + provider := prismProvider(t, srv) + lm, err := provider.LanguageModel(context.Background(), "prism") + require.NoError(t, err) + + response, err := lm.Generate(context.Background(), fantasy.Call{Prompt: prompt()}) + require.NoError(t, err) + requirePrismTrailerField(t, response.ProviderMetadata) +} + +func TestResponseTrailers_Stream(t *testing.T) { + t.Parallel() + srv := servePrismTrailers(t) + provider := prismProvider(t, srv) + lm, err := provider.LanguageModel(context.Background(), "prism") + require.NoError(t, err) + + stream, err := lm.Stream(context.Background(), fantasy.Call{Prompt: prompt()}) + require.NoError(t, err) + var finish *fantasy.StreamPart + for part := range stream { + if part.Type == fantasy.StreamPartTypeFinish { + finish = &part + } + } + require.NotNil(t, finish, "expected a finish part") + requirePrismTrailerField(t, finish.ProviderMetadata) +} diff --git a/providers/openaicompat/language_model_hooks.go b/providers/openaicompat/language_model_hooks.go index daa616d5a..740292d34 100644 --- a/providers/openaicompat/language_model_hooks.go +++ b/providers/openaicompat/language_model_hooks.go @@ -13,7 +13,10 @@ import ( "github.com/openai/openai-go/v3/shared" ) -const reasoningStartedCtx = "reasoning_started" +const ( + reasoningStartedCtx = "reasoning_started" + reasoningEndedCtx = "reasoning_ended" +) // buildTextBlock creates a text content block, applying any provider-specific extra // fields (e.g. Qwen's cache_control) onto it. Part-level fields override message-level @@ -80,20 +83,20 @@ func ExtraContentFunc(choice openaisdk.ChatCompletionChoice) []fantasy.Content { if err != nil { return content } - if rc := reasoningData.GetReasoningContent(); rc != "" { + if hasReasoningField(choice.Message.RawJSON()) { content = append(content, fantasy.ReasoningContent{ - Text: rc, + Text: reasoningData.GetReasoningContent(), }) } return content } -func extractReasoningContext(ctx map[string]any) bool { - reasoningStarted, ok := ctx[reasoningStartedCtx] +func ctxBool(ctx map[string]any, key string) bool { + v, ok := ctx[key] if !ok { return false } - b, ok := reasoningStarted.(bool) + b, ok := v.(bool) if !ok { return false } @@ -106,9 +109,17 @@ func StreamExtraFunc(chunk openaisdk.ChatCompletionChunk, yield func(fantasy.Str return ctx, true } - reasoningStarted := extractReasoningContext(ctx) + for _, choice := range chunk.Choices { + // Reasoning state is tracked per choice: the openai language model + // invokes this hook once per chunk, a chunk may carry several + // choices, and providers may emit them in any slice order — key on + // the choice's own Index, not its position in the chunk. + inx := choice.Index + startedKey := fmt.Sprintf("%s:%d", reasoningStartedCtx, inx) + endedKey := fmt.Sprintf("%s:%d", reasoningEndedCtx, inx) + reasoningStarted := ctxBool(ctx, startedKey) + reasoningEnded := ctxBool(ctx, endedKey) - for inx, choice := range chunk.Choices { reasoningData := ReasoningData{} err := json.Unmarshal([]byte(choice.Delta.RawJSON()), &reasoningData) if err != nil { @@ -119,35 +130,54 @@ func StreamExtraFunc(chunk openaisdk.ChatCompletionChunk, yield func(fantasy.Str return ctx, false } - emitEvent := func(reasoningContent string) bool { + rc := reasoningData.GetReasoningContent() + hasField := hasReasoningField(choice.Delta.RawJSON()) + boundary := choice.Delta.Content != "" || len(choice.Delta.ToolCalls) > 0 || choice.FinishReason != "" + + // A reasoning delta carries non-empty reasoning text, or a + // present-but-empty field on a chunk that carries nothing else before + // any block was closed (Kimi's "thinking on, nothing to think" + // shape). A boundary chunk whose reasoning field is empty/null is + // never reasoning. + if rc != "" || (hasField && !boundary && !reasoningEnded) { if !reasoningStarted { - shouldContinue := yield(fantasy.StreamPart{ + reasoningStarted = true + ctx[startedKey] = true + if !yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeReasoningStart, ID: fmt.Sprintf("%d", inx), - }) - if !shouldContinue { - return false + }) { + return ctx, false } } - - return yield(fantasy.StreamPart{ - Type: fantasy.StreamPartTypeReasoningDelta, - ID: fmt.Sprintf("%d", inx), - Delta: reasoningContent, - }) - } - if rc := reasoningData.GetReasoningContent(); rc != "" { - if !reasoningStarted { - ctx[reasoningStartedCtx] = true + // Skip empty deltas: a present-but-empty field opens the block so + // it replays as reasoning_content: "", but there is nothing to + // stream. + if rc != "" { + if !yield(fantasy.StreamPart{ + Type: fantasy.StreamPartTypeReasoningDelta, + ID: fmt.Sprintf("%d", inx), + Delta: rc, + }) { + return ctx, false + } } - return ctx, emitEvent(rc) + // Fall through: a batching host may put the reasoning tail and the + // first content/tool-call token in the same delta. } - if reasoningStarted && (choice.Delta.Content != "" || len(choice.Delta.ToolCalls) > 0) { - ctx[reasoningStartedCtx] = false - return ctx, yield(fantasy.StreamPart{ + if reasoningStarted && boundary { + ctx[startedKey] = false + ctx[endedKey] = true + // The openai main loop runs this hook before emitting a chunk's + // text/tool-call parts, so on a batched boundary chunk the part order + // is ReasoningDelta(tail), ReasoningEnd, then the content/tool parts: + // the reasoning in that delta semantically precedes the content. + if !yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeReasoningEnd, ID: fmt.Sprintf("%d", inx), - }) + }) { + return ctx, false + } } } return ctx, true @@ -159,15 +189,10 @@ func StreamExtraFunc(chunk openaisdk.ChatCompletionChunk, yield func(fantasy.Str func ToPromptFunc(prompt fantasy.Prompt, _, _ string) ([]openaisdk.ChatCompletionMessageParamUnion, []fantasy.CallWarning) { var messages []openaisdk.ChatCompletionMessageParamUnion var warnings []fantasy.CallWarning - hasReasoning := false - // All tool replies in a batch must precede synthetic user media. - var pendingToolMedia []openaisdk.ChatCompletionMessageParamUnion + var media openai.ToolRunBuffer for _, msg := range prompt { - if msg.Role != fantasy.MessageRoleTool { - messages = append(messages, pendingToolMedia...) - pendingToolMedia = nil - } + messages = media.Role(msg.Role, messages) switch msg.Role { case fantasy.MessageRoleSystem: var blocks []openaisdk.ChatCompletionContentPartTextParam @@ -345,7 +370,7 @@ func ToPromptFunc(prompt fantasy.Prompt, _, _ string) ([]openaisdk.ChatCompletio } } } - if !hasVisibleCompatUserContent(content) { + if !openai.HasVisibleUserContent(content) { warnings = append(warnings, fantasy.CallWarning{ Type: fantasy.CallWarningTypeOther, Message: "dropping empty user message (contains neither user-facing content nor tool results)", @@ -378,7 +403,16 @@ func ToPromptFunc(prompt fantasy.Prompt, _, _ string) ([]openaisdk.ChatCompletio assistantMsg := openaisdk.ChatCompletionAssistantMessageParam{ Role: "assistant", } - var reasoningText string + // A turn may carry several reasoning or text segments (a model that + // reasons, speaks, then reasons again). Concatenate in order rather + // than last-write-wins so no segment is silently dropped (F2, + // CHARM-2020). Text segments are collected as ordered blocks; the + // string form is used only for a single segment with no extra + // fields, so mixed plain/extra turns keep their original order. + var reasoningTexts []string + reasoningPresent := false + var textBlocks []openaisdk.ChatCompletionAssistantMessageParamContentArrayOfContentPartUnion + textHasExtra := false for _, c := range msg.Content { switch c.GetType() { case fantasy.ContentTypeText: @@ -391,17 +425,10 @@ func ToPromptFunc(prompt fantasy.Prompt, _, _ string) ([]openaisdk.ChatCompletio continue } textBlock, hasExtra := buildTextBlock(textPart.Text, textPart.ProviderOptions, msg.ProviderOptions) - if hasExtra { - assistantMsg.Content = openaisdk.ChatCompletionAssistantMessageParamContentUnion{ - OfArrayOfContentParts: []openaisdk.ChatCompletionAssistantMessageParamContentArrayOfContentPartUnion{ - {OfText: textBlock}, - }, - } - } else { - assistantMsg.Content = openaisdk.ChatCompletionAssistantMessageParamContentUnion{ - OfString: param.NewOpt(textPart.Text), - } - } + textHasExtra = textHasExtra || hasExtra + textBlocks = append(textBlocks, openaisdk.ChatCompletionAssistantMessageParamContentArrayOfContentPartUnion{ + OfText: textBlock, + }) case fantasy.ContentTypeReasoning: reasoningPart, ok := fantasy.AsContentType[fantasy.ReasoningPart](c) if !ok { @@ -411,8 +438,8 @@ func ToPromptFunc(prompt fantasy.Prompt, _, _ string) ([]openaisdk.ChatCompletio }) continue } - reasoningText = reasoningPart.Text - hasReasoning = true + reasoningTexts = append(reasoningTexts, reasoningPart.Text) + reasoningPresent = true case fantasy.ContentTypeToolCall: toolCallPart, ok := fantasy.AsContentType[fantasy.ToolCallPart](c) if !ok { @@ -435,15 +462,33 @@ func ToPromptFunc(prompt fantasy.Prompt, _, _ string) ([]openaisdk.ChatCompletio }) } } - // Add reasoning_content field if present, or if thinking is enabled - // and the message has tool calls (some providers like Kimi require - // reasoning_content on all assistant messages when thinking is enabled). - if reasoningText != "" || (hasReasoning && len(assistantMsg.ToolCalls) > 0) { + // Text: when no segment carries extra fields, join into the string + // form; otherwise emit the ordered blocks one per segment so mixed + // plain/extra turns keep their original order. + if len(textBlocks) > 0 && !textHasExtra { + texts := make([]string, len(textBlocks)) + for i, b := range textBlocks { + texts[i] = b.OfText.Text + } + assistantMsg.Content = openaisdk.ChatCompletionAssistantMessageParamContentUnion{ + OfString: param.NewOpt(strings.Join(texts, "\n")), + } + } else if len(textBlocks) > 0 { + assistantMsg.Content = openaisdk.ChatCompletionAssistantMessageParamContentUnion{ + OfArrayOfContentParts: textBlocks, + } + } + // Add reasoning_content field if the message carries a reasoning + // part, even when its text is empty: presence must mirror what the + // model emitted so resubmitted history stays byte-stable for prefix + // caching. Providers like Kimi require the field on assistant + // tool-call messages when thinking is enabled. + if reasoningPresent { assistantMsg.SetExtraFields(map[string]any{ - "reasoning_content": reasoningText, + "reasoning_content": strings.Join(reasoningTexts, "\n"), }) } - if !hasVisibleCompatAssistantContent(&assistantMsg) { + if !openai.HasVisibleAssistantContent(&assistantMsg) { warnings = append(warnings, fantasy.CallWarning{ Type: fantasy.CallWarningTypeOther, Message: "dropping empty assistant message (contains neither user-facing content nor tool calls)", @@ -454,96 +499,11 @@ func ToPromptFunc(prompt fantasy.Prompt, _, _ string) ([]openaisdk.ChatCompletio OfAssistant: &assistantMsg, }) case fantasy.MessageRoleTool: - for _, c := range msg.Content { - if c.GetType() != fantasy.ContentTypeToolResult { - warnings = append(warnings, fantasy.CallWarning{ - Type: fantasy.CallWarningTypeOther, - Message: "tool message can only have tool result content", - }) - continue - } - - toolResultPart, ok := fantasy.AsContentType[fantasy.ToolResultPart](c) - if !ok { - warnings = append(warnings, fantasy.CallWarning{ - Type: fantasy.CallWarningTypeOther, - Message: "tool message result part does not have the right type", - }) - continue - } - - switch toolResultPart.Output.GetType() { - case fantasy.ToolResultContentTypeText: - output, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentText](toolResultPart.Output) - if !ok { - warnings = append(warnings, fantasy.CallWarning{ - Type: fantasy.CallWarningTypeOther, - Message: "tool result output does not have the right type", - }) - continue - } - messages = append(messages, openaisdk.ToolMessage(output.Text, toolResultPart.ToolCallID)) - case fantasy.ToolResultContentTypeError: - output, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentError](toolResultPart.Output) - if !ok { - warnings = append(warnings, fantasy.CallWarning{ - Type: fantasy.CallWarningTypeOther, - Message: "tool result output does not have the right type", - }) - continue - } - messages = append(messages, openaisdk.ToolMessage(output.Error.Error(), toolResultPart.ToolCallID)) - case fantasy.ToolResultContentTypeMedia: - output, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentMedia](toolResultPart.Output) - if !ok { - warnings = append(warnings, fantasy.CallWarning{ - Type: fantasy.CallWarningTypeOther, - Message: "tool result output does not have the right type", - }) - continue - } - // OpenAI-compatible chat completions tool messages cannot - // carry image or audio content directly; the SDK's content - // union only accepts text. Reuse the openai provider's - // helper, which emits a text tool message plus a synthetic - // user message holding the media. - mediaMessages, mediaWarnings := openai.ToolResultMediaMessages(output, toolResultPart.ToolCallID) - messages = append(messages, mediaMessages[0]) - pendingToolMedia = append(pendingToolMedia, mediaMessages[1:]...) - warnings = append(warnings, mediaWarnings...) - default: - warnings = append(warnings, fantasy.CallWarning{ - Type: fantasy.CallWarningTypeOther, - Message: fmt.Sprintf("tool result output type %q not supported", toolResultPart.Output.GetType()), - }) - } - } - } - } - return append(messages, pendingToolMedia...), warnings -} - -// toolResultMediaUserPart maps a tool-result media output to an OpenAI chat -// completions user content part. It returns the content part, an optional -// warning, and whether the caller should emit the returned part. - -func hasVisibleCompatUserContent(content []openaisdk.ChatCompletionContentPartUnionParam) bool { - for _, part := range content { - if part.OfText != nil || part.OfImageURL != nil || part.OfInputAudio != nil || part.OfFile != nil { - return true + toolMessages, deferred, toolWarnings := openai.ToolMessages(msg, nil) + messages = append(messages, toolMessages...) + media.Defer(deferred...) + warnings = append(warnings, toolWarnings...) } } - return false -} - -func hasVisibleCompatAssistantContent(msg *openaisdk.ChatCompletionAssistantMessageParam) bool { - // Check if there's text content - if !param.IsOmitted(msg.Content.OfString) || len(msg.Content.OfArrayOfContentParts) > 0 { - return true - } - // Check if there are tool calls - if len(msg.ToolCalls) > 0 { - return true - } - return false + return media.Close(messages), warnings } diff --git a/providers/openaicompat/openaicompat_test.go b/providers/openaicompat/openaicompat_test.go index 3482a3d58..dab71f44b 100644 --- a/providers/openaicompat/openaicompat_test.go +++ b/providers/openaicompat/openaicompat_test.go @@ -5,6 +5,7 @@ import ( "testing" "charm.land/fantasy" + "github.com/openai/openai-go/v3/packages/param" "github.com/stretchr/testify/require" ) @@ -62,7 +63,7 @@ func TestToPromptFunc_ReasoningContent(t *testing.T) { require.Equal(t, "What about 3+3?", msg3.Content.OfString.Value) }) - t.Run("should handle assistant messages with only reasoning content", func(t *testing.T) { + t.Run("should drop assistant messages with only reasoning content", func(t *testing.T) { t.Parallel() prompt := fantasy.Prompt{ @@ -82,11 +83,14 @@ func TestToPromptFunc_ReasoningContent(t *testing.T) { messages, warnings := ToPromptFunc(prompt, "", "") + // A reasoning-only turn carries neither content nor tool calls, and + // strict upstreams reject that shape (charmbracelet/crush#3794). + // The DeepSeek-family replay contract only covers turns that also + // carry content or tool calls, so dropping is safe. require.Len(t, warnings, 1) require.Contains(t, warnings[0].Message, "dropping empty assistant message") - require.Len(t, messages, 1) // Only user message, assistant message dropped + require.Len(t, messages, 1) - // User message - unchanged msg := messages[0].OfUser require.NotNil(t, msg) require.Equal(t, "Hello", msg.Content.OfString.Value) @@ -158,7 +162,7 @@ func TestToPromptFunc_ReasoningContent(t *testing.T) { require.Equal(t, "Hello", userMsg.Content.OfString.Value) }) - t.Run("should use last assistant TextPart only", func(t *testing.T) { + t.Run("should concatenate assistant TextParts in order", func(t *testing.T) { t.Parallel() prompt := fantasy.Prompt{ @@ -171,8 +175,8 @@ func TestToPromptFunc_ReasoningContent(t *testing.T) { { Role: fantasy.MessageRoleAssistant, Content: []fantasy.MessagePart{ - fantasy.TextPart{Text: "First part. "}, - fantasy.TextPart{Text: "Second part. "}, + fantasy.TextPart{Text: "First part."}, + fantasy.TextPart{Text: "Second part."}, fantasy.TextPart{Text: "Third part."}, }, }, @@ -183,10 +187,11 @@ func TestToPromptFunc_ReasoningContent(t *testing.T) { require.Empty(t, warnings) require.Len(t, messages, 2) - // Assistant message should use only the last TextPart (matching openai behavior) + // Multi-segment assistant turns concatenate (F2, CHARM-2020): + // last-write-wins silently dropped every segment but the last. assistantMsg := messages[1].OfAssistant require.NotNil(t, assistantMsg) - require.Equal(t, "Third part.", assistantMsg.Content.OfString.Value) + require.Equal(t, "First part.\nSecond part.\nThird part.", assistantMsg.Content.OfString.Value) }) t.Run("should include user messages with only unsupported attachments", func(t *testing.T) { @@ -347,12 +352,14 @@ func TestToPromptFunc_DropsEmptyMessages(t *testing.T) { require.Empty(t, warnings) }) - t.Run("should add empty reasoning_content to tool call messages when thinking is enabled", func(t *testing.T) { + t.Run("should add reasoning_content to tool call messages only when the message itself reasoned", func(t *testing.T) { t.Parallel() - // When thinking is enabled (reasoning parts exist in history), - // tool call messages without their own reasoning must still include - // reasoning_content. Providers like Kimi require it. + // reasoning_content presence is decided per message from its own + // reasoning part, never from conversation history. This keeps + // resubmitted history byte-stable for prefix caches (see #330), + // while still emitting the (possibly empty) field that providers + // like Kimi require on assistant tool-call messages. prompt := fantasy.Prompt{ { Role: fantasy.MessageRoleUser, @@ -392,13 +399,56 @@ func TestToPromptFunc_DropsEmptyMessages(t *testing.T) { require.Empty(t, warnings) require.Len(t, messages, 4) - // Tool call message must have reasoning_content (empty) since - // thinking is enabled in this conversation + // The reasoning turn keeps its reasoning_content. + reasoningMsg := messages[1].OfAssistant + require.NotNil(t, reasoningMsg) + rc, ok := reasoningMsg.ExtraFields()["reasoning_content"] + require.True(t, ok) + require.Equal(t, "Simple math.", rc) + + // The tool-call message has no reasoning part of its own, so no + // reasoning_content is manufactured for it. msg := messages[3].OfAssistant require.NotNil(t, msg) - extraFields := msg.ExtraFields() - reasoningContent, hasReasoning := extraFields["reasoning_content"] - require.True(t, hasReasoning, "reasoning_content must be present on tool call messages when thinking is enabled") + _, hasReasoning := msg.ExtraFields()["reasoning_content"] + require.False(t, hasReasoning, "reasoning_content must not be synthesized from history (see #330)") + }) + + t.Run("should emit empty reasoning_content when the tool call message has an empty reasoning part", func(t *testing.T) { + t.Parallel() + + // A present-but-empty reasoning part on the message itself still + // emits reasoning_content: "" (present), satisfying providers like + // Kimi while staying byte-stable. + prompt := fantasy.Prompt{ + { + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "Run it"}, + }, + }, + { + Role: fantasy.MessageRoleAssistant, + Content: []fantasy.MessagePart{ + fantasy.ReasoningPart{Text: ""}, + fantasy.ToolCallPart{ + ToolCallID: "call_1", + ToolName: "execute", + Input: `{"command":"echo 4"}`, + }, + }, + }, + } + + messages, warnings := ToPromptFunc(prompt, "", "") + + require.Empty(t, warnings) + require.Len(t, messages, 2) + + msg := messages[1].OfAssistant + require.NotNil(t, msg) + reasoningContent, hasReasoning := msg.ExtraFields()["reasoning_content"] + require.True(t, hasReasoning, "present-but-empty reasoning part must emit reasoning_content") require.Equal(t, "", reasoningContent) }) @@ -850,3 +900,162 @@ func TestToPromptFunc_ContentExtraFields(t *testing.T) { require.Equal(t, "ephemeral", cacheControl["type"]) }) } + +// TestToPromptFunc_MultiSegmentConcatenation covers the CHARM-2020 F2 case: +// an assistant turn with interleaved reasoning segments (a model that +// reasons, speaks, then reasons again) must not lose segments — text and +// reasoning each concatenate in order instead of last-write-wins. +func TestToPromptFunc_MultiSegmentConcatenation(t *testing.T) { + t.Parallel() + + prompt := fantasy.Prompt{ + { + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "hi"}, + }, + }, + { + Role: fantasy.MessageRoleAssistant, + Content: []fantasy.MessagePart{ + fantasy.ReasoningPart{Text: "a"}, + fantasy.TextPart{Text: "x"}, + fantasy.ToolCallPart{ToolCallID: "c1", ToolName: "f", Input: "{}"}, + fantasy.ReasoningPart{Text: "b"}, + fantasy.TextPart{Text: "y"}, + }, + }, + } + + messages, warnings := ToPromptFunc(prompt, "", "") + require.Empty(t, warnings) + require.Len(t, messages, 2) + + msg := messages[1].OfAssistant + require.NotNil(t, msg) + require.Equal(t, "x\ny", msg.Content.OfString.Value, "text parts must concatenate, not overwrite") + require.Len(t, msg.ToolCalls, 1) + require.Equal(t, "c1", msg.ToolCalls[0].OfFunction.ID) + + extraFields := msg.ExtraFields() + reasoningContent, hasReasoning := extraFields["reasoning_content"] + require.True(t, hasReasoning) + require.Equal(t, "a\nb", reasoningContent, "reasoning parts must concatenate, not overwrite") +} + +// Mixed plain and extra-field text segments must keep their original +// order: A (plain), B (cached), C (plain) must serialize as A, B, C. +func TestToPromptFunc_MixedTextSegmentOrder(t *testing.T) { + t.Parallel() + + prompt := fantasy.Prompt{ + { + Role: fantasy.MessageRoleAssistant, + Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "A"}, + fantasy.TextPart{ + Text: "B", + ProviderOptions: fantasy.ProviderOptions{ + Name: &ContentExtraFields{Fields: map[string]any{ + "cache_control": map[string]string{"type": "ephemeral"}, + }}, + }, + }, + fantasy.TextPart{Text: "C"}, + }, + }, + } + + messages, warnings := ToPromptFunc(prompt, "", "") + require.Empty(t, warnings) + require.Len(t, messages, 1) + + msg := messages[0].OfAssistant + require.NotNil(t, msg) + blocks := msg.Content.OfArrayOfContentParts + require.Len(t, blocks, 3, "expected array form with one block per segment, got %+v", msg.Content) + require.Equal(t, "A", blocks[0].OfText.Text) + require.Equal(t, "B", blocks[1].OfText.Text) + require.Equal(t, "C", blocks[2].OfText.Text) + require.Equal(t, map[string]any{"cache_control": map[string]string{"type": "ephemeral"}}, blocks[1].OfText.ExtraFields()) +} + +// A reasoning-only assistant turn must be dropped: it serializes with +// neither content nor tool calls, which strict OpenAI-compatible upstreams +// reject ("content or tool_calls must be set"), locking the session once +// such a turn lands in history (charmbracelet/crush#3794). DeepSeek's +// replay contract only requires reasoning_content on turns that also carry +// content or tool calls; those are unaffected. +func TestToPromptFunc_ReasoningOnlyTurnDropped(t *testing.T) { + t.Parallel() + + prompt := fantasy.Prompt{ + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{fantasy.TextPart{Text: "hi"}}}, + {Role: fantasy.MessageRoleAssistant, Content: []fantasy.MessagePart{ + fantasy.ReasoningPart{Text: "thinking…"}, + }}, + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{fantasy.TextPart{Text: "still there?"}}}, + } + + messages, warnings := ToPromptFunc(prompt, "", "") + require.Len(t, warnings, 1) + require.Contains(t, warnings[0].Message, "dropping empty assistant message") + require.Len(t, messages, 2, "reasoning-only assistant turn must be dropped") + for _, m := range messages { + require.NotNil(t, m.OfUser, "only user messages remain") + } +} + +// Regression test for charmbracelet/crush#3794: a turn canceled while the +// model was still thinking persists as an assistant message whose only part +// is reasoning. Re-sent in history it serializes with neither content nor +// tool calls, which strict upstreams reject ("Invalid assistant message: +// content or tool_calls must be set"), failing every later request. The +// canceled turn must be dropped, while a completed reasoning+tool-call turn +// in the same history keeps its reasoning_content (DeepSeek/Kimi replay). +func TestToPromptFunc_CanceledThinkingTurnDropped(t *testing.T) { + t.Parallel() + + prompt := fantasy.Prompt{ + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{fantasy.TextPart{Text: "check the date"}}}, + {Role: fantasy.MessageRoleAssistant, Content: []fantasy.MessagePart{ + fantasy.ReasoningPart{Text: "I should call get_date."}, + fantasy.ToolCallPart{ToolCallID: "call_1", ToolName: "get_date", Input: "{}"}, + }}, + {Role: fantasy.MessageRoleTool, Content: []fantasy.MessagePart{ + fantasy.ToolResultPart{ + ToolCallID: "call_1", + Output: fantasy.ToolResultOutputContentText{Text: "2026-09-12"}, + }, + }}, + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{fantasy.TextPart{Text: "and the weather?"}}}, + {Role: fantasy.MessageRoleAssistant, Content: []fantasy.MessagePart{ + fantasy.ReasoningPart{Text: "Thinking about weather…"}, // canceled mid-thought + }}, + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{fantasy.TextPart{Text: "are you there?"}}}, + } + + messages, warnings := ToPromptFunc(prompt, "", "") + require.Len(t, warnings, 1) + require.Contains(t, warnings[0].Message, "dropping empty assistant message") + + // user, assistant(tool call), tool, user, user — the canceled turn is gone. + require.Len(t, messages, 5) + + assistant := messages[1].OfAssistant + require.NotNil(t, assistant) + require.Len(t, assistant.ToolCalls, 1) + require.Equal(t, "I should call get_date.", assistant.ExtraFields()["reasoning_content"], + "completed tool-call turns keep reasoning_content") + + // No assistant message may go out with neither content nor tool calls. + for i, m := range messages { + if m.OfAssistant == nil { + continue + } + valid := !param.IsOmitted(m.OfAssistant.Content.OfString) || + len(m.OfAssistant.Content.OfArrayOfContentParts) > 0 || + len(m.OfAssistant.ToolCalls) > 0 + require.True(t, valid, "assistant message %d has neither content nor tool calls", i) + } +} diff --git a/providers/openaicompat/provider_options.go b/providers/openaicompat/provider_options.go index 8c16db999..a0d8160a7 100644 --- a/providers/openaicompat/provider_options.go +++ b/providers/openaicompat/provider_options.go @@ -2,6 +2,7 @@ package openaicompat import ( + "bytes" "encoding/json" "charm.land/fantasy" @@ -54,6 +55,25 @@ func (r ReasoningData) GetReasoningContent() string { return r.Reasoning } +// hasReasoningField reports whether raw JSON contains a reasoning field +// ("reasoning_content" or "reasoning") whose value is a JSON string, +// including the empty string. A null value means "no reasoning in this +// delta": DeepSeek, vLLM and SGLang put "reasoning_content": null on every +// content/tool-call/finish chunk, and counting that as reasoning keeps the +// block open forever. +func hasReasoningField(rawJSON string) bool { + var fields map[string]json.RawMessage + if err := json.Unmarshal([]byte(rawJSON), &fields); err != nil { + return false + } + for _, key := range []string{"reasoning_content", "reasoning"} { + if v, ok := fields[key]; ok && !bytes.Equal(bytes.TrimSpace(v), []byte("null")) { + return true + } + } + return false +} + // Options implements the ProviderOptions interface. func (*ProviderOptions) Options() {} diff --git a/providers/openaicompat/replay_test.go b/providers/openaicompat/replay_test.go new file mode 100644 index 000000000..fa9c2b527 --- /dev/null +++ b/providers/openaicompat/replay_test.go @@ -0,0 +1,796 @@ +package openaicompat + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "charm.land/fantasy" + "github.com/stretchr/testify/require" +) + +// serveSSE returns an httptest server that answers streaming POSTs with the +// given SSE payloads in order (one per request), flushing after each event. +// It records every request body it receives. The special marker line +// "" hijacks and closes the connection without a [DONE]. +// Non-streaming requests get a canned JSON chat.completion reply. +func serveSSE(t *testing.T, payloads ...string) (*httptest.Server, *[][]byte) { + t.Helper() + var bodies [][]byte + var calls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + bodies = append(bodies, body) + + var reqShape struct { + Stream bool `json:"stream"` + } + _ = json.Unmarshal(body, &reqShape) + if !reqShape.Stream { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{"id":"y","created":1,"model":"deepseek-v4-flash","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"The date is 2026-08-28."},"finish_reason":"stop"}],"usage":{"prompt_tokens":200,"completion_tokens":10,"total_tokens":210}}`) + return + } + + payload := payloads[min(calls, len(payloads)-1)] + calls++ + + w.Header().Set("Content-Type", "text/event-stream") + rc := http.NewResponseController(w) + for event := range strings.Lines(payload) { + if strings.TrimSpace(event) == "" { + _ = rc.Flush() + hj, ok := w.(http.Hijacker) + if !ok { + return + } + conn, _, err := hj.Hijack() + if err != nil { + return + } + _ = conn.Close() + return + } + if strings.TrimSpace(event) == "" { + continue + } + fmt.Fprintf(w, "data: %s\n\n", strings.TrimSpace(event)) + _ = rc.Flush() + } + _, _ = fmt.Fprint(w, "data: [DONE]\n\n") + _ = rc.Flush() + })) + t.Cleanup(srv.Close) + return srv, &bodies +} + +func streamParts(t *testing.T, lm fantasy.LanguageModel, prompt fantasy.Prompt) []fantasy.StreamPart { + t.Helper() + stream, err := lm.Stream(context.Background(), fantasy.Call{Prompt: prompt}) + require.NoError(t, err) + var parts []fantasy.StreamPart + for part := range stream { + parts = append(parts, part) + } + return parts +} + +func partTypes(parts []fantasy.StreamPart) []fantasy.StreamPartType { + types := make([]fantasy.StreamPartType, 0, len(parts)) + for _, p := range parts { + types = append(types, p.Type) + } + return types +} + +// countParts counts parts of a type; for reasoning deltas it also +// concatenates the deltas. +func reasoningText(parts []fantasy.StreamPart) string { + var sb strings.Builder + for _, p := range parts { + if p.Type == fantasy.StreamPartTypeReasoningDelta { + sb.WriteString(p.Delta) + } + } + return sb.String() +} + +func countType(parts []fantasy.StreamPart, typ fantasy.StreamPartType) int { + n := 0 + for _, p := range parts { + if p.Type == typ { + n++ + } + } + return n +} + +// A.1 from CHARM-2020: DeepSeek-shaped thinking + tool call. Every delta +// carries both keys; non-reasoning chunks have "reasoning_content": null. +const deepseekThinkingToolCallSSE = `{"id":"x","created":1,"model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning_content":null},"finish_reason":null}]} +{"id":"x","created":1,"model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"content":null,"reasoning_content":"The user wants the date."},"finish_reason":null}]} +{"id":"x","created":1,"model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"content":null,"reasoning_content":" I'll call get_date."},"finish_reason":null}]} +{"id":"x","created":1,"model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"content":"Let me check.","reasoning_content":null},"finish_reason":null}]} +{"id":"x","created":1,"model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"content":null,"reasoning_content":null,"tool_calls":[{"index":0,"id":"call_00_abc","type":"function","function":{"name":"get_date","arguments":""}}]},"finish_reason":null}]} +{"id":"x","created":1,"model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"content":null,"reasoning_content":null,"tool_calls":[{"index":0,"function":{"arguments":"{}"}}]},"finish_reason":null}]} +{"id":"x","created":1,"model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"content":"","reasoning_content":null},"finish_reason":"tool_calls"}]} +{"id":"x","created":1,"model":"deepseek-v4-flash","choices":[],"usage":{"prompt_tokens":120,"completion_tokens":40,"total_tokens":160}}` + +const cannedStopReplySSE = `{"id":"y","created":1,"model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"role":"assistant","content":"The date is 2026-08-28."},"finish_reason":null}]} +{"id":"y","created":1,"model":"deepseek-v4-flash","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} +{"id":"y","created":1,"model":"deepseek-v4-flash","choices":[],"usage":{"prompt_tokens":200,"completion_tokens":10,"total_tokens":210}}` + +// TestReplay_DeepSeekThinkingToolCall drives fixture A.1 through a real +// openaicompat model and asserts the reasoning block survives end-to-end: +// exactly one ReasoningStart, the concatenated deltas, exactly one +// ReasoningEnd before Finish, and no empty ReasoningDeltas. +func TestReplay_DeepSeekThinkingToolCall(t *testing.T) { + srv, _ := serveSSE(t, deepseekThinkingToolCallSSE) + provider, err := New(WithBaseURL(srv.URL), WithAPIKey("x")) + require.NoError(t, err) + lm, err := provider.LanguageModel(context.Background(), "deepseek-v4-flash") + require.NoError(t, err) + + parts := streamParts(t, lm, fantasy.Prompt{ + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{fantasy.TextPart{Text: "date?"}}}, + }) + + require.Equal(t, 1, countType(parts, fantasy.StreamPartTypeReasoningStart), "types: %v", partTypes(parts)) + require.Equal(t, 1, countType(parts, fantasy.StreamPartTypeReasoningEnd), "types: %v", partTypes(parts)) + require.Equal(t, "The user wants the date. I'll call get_date.", reasoningText(parts)) + for _, p := range parts { + if p.Type == fantasy.StreamPartTypeReasoningDelta { + require.NotEmpty(t, p.Delta, "empty ReasoningDelta emitted") + } + } + // ReasoningEnd must come before Finish. + endIdx, finishIdx := -1, -1 + for i, p := range parts { + if p.Type == fantasy.StreamPartTypeReasoningEnd { + endIdx = i + } + if p.Type == fantasy.StreamPartTypeFinish { + finishIdx = i + } + } + require.Greater(t, endIdx, -1) + require.Greater(t, finishIdx, endIdx, "ReasoningEnd must precede Finish: %v", partTypes(parts)) + require.Equal(t, 1, countType(parts, fantasy.StreamPartTypeToolCall), "types: %v", partTypes(parts)) +} + +// TestReplay_DeepSeekReasoningRoundTripsToNextRequest is the regression test +// for crush#2696 / CHARM-2020: the reasoning the model produced must be sent +// back, byte-equal, on the assistant message carrying tool_calls in the next +// request. We drive two steps manually (no agent) to keep the harness small. +func TestReplay_DeepSeekReasoningRoundTripsToNextRequest(t *testing.T) { + srv, bodies := serveSSE(t, deepseekThinkingToolCallSSE, cannedStopReplySSE) + provider, err := New(WithBaseURL(srv.URL), WithAPIKey("x")) + require.NoError(t, err) + lm, err := provider.LanguageModel(context.Background(), "deepseek-v4-flash") + require.NoError(t, err) + + // Step 1: collect reasoning + tool call from the stream. + parts := streamParts(t, lm, fantasy.Prompt{ + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{fantasy.TextPart{Text: "date?"}}}, + }) + reasoning := reasoningText(parts) + require.NotEmpty(t, reasoning) + var toolCall *fantasy.StreamPart + for i, p := range parts { + if p.Type == fantasy.StreamPartTypeToolCall { + toolCall = &parts[i] + } + } + require.NotNil(t, toolCall, "types: %v", partTypes(parts)) + + // Step 2: replay the assistant message the way a faithful client would + // and assert on the request body the provider sends upstream. Non-stream + // so the server answers with JSON. + resp, err := lm.Generate(context.Background(), fantasy.Call{Prompt: fantasy.Prompt{ + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{fantasy.TextPart{Text: "date?"}}}, + {Role: fantasy.MessageRoleAssistant, Content: []fantasy.MessagePart{ + fantasy.ReasoningPart{Text: reasoning}, + fantasy.TextPart{Text: "Let me check."}, + fantasy.ToolCallPart{ToolCallID: toolCall.ID, ToolName: toolCall.ToolCallName, Input: toolCall.ToolCallInput}, + }}, + {Role: fantasy.MessageRoleTool, Content: []fantasy.MessagePart{ + fantasy.ToolResultPart{ToolCallID: toolCall.ID, Output: fantasy.ToolResultOutputContentText{Text: "2026-08-28"}}, + }}, + }}) + require.NoError(t, err) + require.NotNil(t, resp) + + require.Len(t, *bodies, 2) + var second struct { + Messages []struct { + Role string `json:"role"` + ReasoningContent *string `json:"reasoning_content"` + ToolCalls []struct { + ID string `json:"id"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + } `json:"tool_calls"` + } `json:"messages"` + } + require.NoError(t, json.Unmarshal((*bodies)[1], &second)) + var assistant *struct { + Role string `json:"role"` + ReasoningContent *string `json:"reasoning_content"` + ToolCalls []struct { + ID string `json:"id"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + } `json:"tool_calls"` + } + for i := range second.Messages { + if second.Messages[i].Role == "assistant" { + assistant = &second.Messages[i] + } + } + require.NotNil(t, assistant) + require.NotNil(t, assistant.ReasoningContent, "reasoning_content missing on replayed assistant message: %s", (*bodies)[1]) + require.Equal(t, reasoning, *assistant.ReasoningContent, "reasoning_content must round-trip byte-for-byte") + require.Len(t, assistant.ToolCalls, 1) + require.Equal(t, "call_00_abc", assistant.ToolCalls[0].ID) + require.Equal(t, "get_date", assistant.ToolCalls[0].Function.Name) + require.Equal(t, "{}", assistant.ToolCalls[0].Function.Arguments) +} + +// A.2: reasoning-only response truncated by length. ReasoningEnd must still +// be emitted (on the finish chunk) and the reasoning kept. +const deepseekReasoningOnlyLengthSSE = `{"id":"x","created":1,"model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning_content":null},"finish_reason":null}]} +{"id":"x","created":1,"model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"content":null,"reasoning_content":"Thinking about it"},"finish_reason":null}]} +{"id":"x","created":1,"model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"content":null,"reasoning_content":" at length…"},"finish_reason":null}]} +{"id":"x","created":1,"model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"content":"","reasoning_content":null},"finish_reason":"length"}]}` + +func TestReplay_DeepSeekReasoningOnlyTruncated(t *testing.T) { + srv, _ := serveSSE(t, deepseekReasoningOnlyLengthSSE) + provider, err := New(WithBaseURL(srv.URL), WithAPIKey("x")) + require.NoError(t, err) + lm, err := provider.LanguageModel(context.Background(), "deepseek-v4-flash") + require.NoError(t, err) + + parts := streamParts(t, lm, fantasy.Prompt{ + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{fantasy.TextPart{Text: "hi"}}}, + }) + require.Equal(t, "Thinking about it at length…", reasoningText(parts)) + require.Equal(t, 1, countType(parts, fantasy.StreamPartTypeReasoningEnd), "types: %v", partTypes(parts)) + var finish *fantasy.StreamPart + for i, p := range parts { + if p.Type == fantasy.StreamPartTypeFinish { + finish = &parts[i] + } + } + require.NotNil(t, finish) + require.Equal(t, fantasy.FinishReasonLength, finish.FinishReason) +} + +// A.3: a batching host puts the reasoning tail and the whole tool call in +// one delta. The reasoning must be kept and the tool call intact. +const deepseekBatchedBoundarySSE = `{"id":"x","created":1,"model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"content":null,"reasoning_content":"Need the date."},"finish_reason":null}]} +{"id":"x","created":1,"model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"content":null,"reasoning_content":" Calling.","tool_calls":[{"index":0,"id":"call_00_x","type":"function","function":{"name":"get_date","arguments":"{}"}}]},"finish_reason":null}]} +{"id":"x","created":1,"model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"content":"","reasoning_content":null},"finish_reason":"tool_calls"}]}` + +func TestReplay_DeepSeekBatchedBoundaryChunk(t *testing.T) { + srv, _ := serveSSE(t, deepseekBatchedBoundarySSE) + provider, err := New(WithBaseURL(srv.URL), WithAPIKey("x")) + require.NoError(t, err) + lm, err := provider.LanguageModel(context.Background(), "deepseek-v4-flash") + require.NoError(t, err) + + parts := streamParts(t, lm, fantasy.Prompt{ + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{fantasy.TextPart{Text: "date?"}}}, + }) + require.Equal(t, "Need the date. Calling.", reasoningText(parts)) + require.Equal(t, 1, countType(parts, fantasy.StreamPartTypeToolCall), "types: %v", partTypes(parts)) + // The reasoning tail rides the same delta as the tool call, so it must + // be yielded before the tool parts: block-based consumers (e.g. + // Anthropic-shaped /messages endpoints) close their reasoning block when + // a tool part opens, and reasoning emitted after it lands in a block + // that was already closed. + lastReasoning, firstTool := -1, -1 + for i, p := range parts { + if p.Type == fantasy.StreamPartTypeReasoningDelta || p.Type == fantasy.StreamPartTypeReasoningEnd { + lastReasoning = i + } + if firstTool == -1 && (p.Type == fantasy.StreamPartTypeToolInputStart || p.Type == fantasy.StreamPartTypeToolCall) { + firstTool = i + } + } + require.Greater(t, lastReasoning, -1) + require.Greater(t, firstTool, -1) + require.Less(t, lastReasoning, firstTool, + "reasoning must precede tool parts on a batched boundary chunk: %v", partTypes(parts)) +} + +// A batching host may also put the reasoning tail and the first content token +// in the same delta. The reasoning must be yielded before the text parts for +// the same reason as above: a consumer that closes its reasoning block when +// text opens cannot place reasoning emitted afterwards. +const deepseekBatchedContentBoundarySSE = `{"id":"x","created":1,"model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning_content":null},"finish_reason":null}]} +{"id":"x","created":1,"model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"content":null,"reasoning_content":"Long thinking about the answer."},"finish_reason":null}]} +{"id":"x","created":1,"model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"content":"Hello","reasoning_content":" Done."},"finish_reason":null}]} +{"id":"x","created":1,"model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"content":" world.","reasoning_content":null},"finish_reason":null}]} +{"id":"x","created":1,"model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"content":"","reasoning_content":null},"finish_reason":"stop"}]}` + +func TestReplay_BatchedContentBoundaryChunkOrder(t *testing.T) { + srv, _ := serveSSE(t, deepseekBatchedContentBoundarySSE) + provider, err := New(WithBaseURL(srv.URL), WithAPIKey("x")) + require.NoError(t, err) + lm, err := provider.LanguageModel(context.Background(), "deepseek-v4-flash") + require.NoError(t, err) + + parts := streamParts(t, lm, fantasy.Prompt{ + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{fantasy.TextPart{Text: "hi"}}}, + }) + require.Equal(t, "Long thinking about the answer. Done.", reasoningText(parts)) + var text strings.Builder + for _, p := range parts { + if p.Type == fantasy.StreamPartTypeTextDelta { + text.WriteString(p.Delta) + } + } + require.Equal(t, "Hello world.", text.String()) + + lastReasoning, firstText := -1, -1 + for i, p := range parts { + if p.Type == fantasy.StreamPartTypeReasoningDelta || p.Type == fantasy.StreamPartTypeReasoningEnd { + lastReasoning = i + } + if firstText == -1 && (p.Type == fantasy.StreamPartTypeTextStart || p.Type == fantasy.StreamPartTypeTextDelta) { + firstText = i + } + } + require.Greater(t, lastReasoning, -1) + require.Greater(t, firstText, -1) + require.Less(t, lastReasoning, firstText, + "reasoning must precede text parts on a batched boundary chunk: %v", partTypes(parts)) +} + +// A.5: Kimi/Avian shape. Reasoning chunks carry reasoning_content, content +// chunks omit the key, the finish chunk has "reasoning_content": null — +// which must not reopen a reasoning block. +const kimiShapeSSE = `{"id":"x","created":1,"model":"kimi","choices":[{"index":0,"delta":{"role":"assistant","reasoning_content":"first"},"finish_reason":null}]} +{"id":"x","created":1,"model":"kimi","choices":[{"index":0,"delta":{"reasoning_content":" second"},"finish_reason":null}]} +{"id":"x","created":1,"model":"kimi","choices":[{"index":0,"delta":{"content":"answer"},"finish_reason":null}]} +{"id":"x","created":1,"model":"kimi","choices":[{"index":0,"delta":{"reasoning_content":null},"finish_reason":"stop"}]}` + +func TestReplay_KimiNullFinishDoesNotReopen(t *testing.T) { + srv, _ := serveSSE(t, kimiShapeSSE) + provider, err := New(WithBaseURL(srv.URL), WithAPIKey("x")) + require.NoError(t, err) + lm, err := provider.LanguageModel(context.Background(), "kimi") + require.NoError(t, err) + + parts := streamParts(t, lm, fantasy.Prompt{ + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{fantasy.TextPart{Text: "hi"}}}, + }) + require.Equal(t, "first second", reasoningText(parts)) + require.Equal(t, 1, countType(parts, fantasy.StreamPartTypeReasoningStart), "types: %v", partTypes(parts)) + require.Equal(t, 1, countType(parts, fantasy.StreamPartTypeReasoningEnd), "types: %v", partTypes(parts)) + for _, p := range parts { + if p.Type == fantasy.StreamPartTypeReasoningDelta { + require.NotEmpty(t, p.Delta, "empty ReasoningDelta emitted (phantom block reopened)") + } + } +} + +// Kimi present-but-empty: a chunk with "reasoning_content": "" before any +// content starts an (empty) reasoning block that closes on the first +// tool-call chunk and replays as "reasoning_content": "". +const kimiEmptyReasoningSSE = `{"id":"x","created":1,"model":"kimi","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning_content":""},"finish_reason":null}]} +{"id":"x","created":1,"model":"kimi","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"f","arguments":"{}"}}]},"finish_reason":null}]} +{"id":"x","created":1,"model":"kimi","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}` + +func TestReplay_KimiPresentButEmpty(t *testing.T) { + srv, _ := serveSSE(t, kimiEmptyReasoningSSE) + provider, err := New(WithBaseURL(srv.URL), WithAPIKey("x")) + require.NoError(t, err) + lm, err := provider.LanguageModel(context.Background(), "kimi") + require.NoError(t, err) + + parts := streamParts(t, lm, fantasy.Prompt{ + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{fantasy.TextPart{Text: "hi"}}}, + }) + require.Equal(t, 1, countType(parts, fantasy.StreamPartTypeReasoningStart), "types: %v", partTypes(parts)) + require.Equal(t, 1, countType(parts, fantasy.StreamPartTypeReasoningEnd), "types: %v", partTypes(parts)) + for _, p := range parts { + if p.Type == fantasy.StreamPartTypeReasoningDelta { + require.NotEmpty(t, p.Delta, "empty ReasoningDelta emitted") + } + } +} + +// A.4: a stream cut mid-arguments with no finish_reason and no [DONE] must +// not surface a complete ToolCall: truncated arguments must never be +// dispatched. +const deepseekCutMidArgumentsSSE = `{"id":"x","created":1,"model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"content":null,"reasoning_content":"Write the file."},"finish_reason":null}]} +{"id":"x","created":1,"model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"content":null,"reasoning_content":null,"tool_calls":[{"index":0,"id":"call_00_y","type":"function","function":{"name":"write","arguments":""}}]},"finish_reason":null}]} +{"id":"x","created":1,"model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"content":null,"reasoning_content":null,"tool_calls":[{"index":0,"function":{"arguments":"{\"path\":\"main.go\",\"content\":\"package ma"}}]},"finish_reason":null}]} +` + +func TestReplay_CutStreamMidArguments_NotDispatched(t *testing.T) { + srv, _ := serveSSE(t, deepseekCutMidArgumentsSSE) + provider, err := New(WithBaseURL(srv.URL), WithAPIKey("x")) + require.NoError(t, err) + lm, err := provider.LanguageModel(context.Background(), "deepseek-v4-flash") + require.NoError(t, err) + + parts := streamParts(t, lm, fantasy.Prompt{ + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{fantasy.TextPart{Text: "write main.go"}}}, + }) + require.Equal(t, 0, countType(parts, fantasy.StreamPartTypeToolCall), + "truncated tool call must not be dispatched; types: %v", partTypes(parts)) + var sawIncomplete bool + for _, p := range parts { + if p.Type == fantasy.StreamPartTypeError { + sawIncomplete = true + } + } + require.True(t, sawIncomplete, "expected an error part (IncompleteStreamError), types: %v", partTypes(parts)) +} + +// TestReplay_AgentReasoningRoundTrip is the regression test for crush#2696 +// and the client-side half of CHARM-2020: drive fixture A.1 through a real +// agent (the loop Crush runs), let it dispatch the tool, and assert on the +// SECOND request body — the assistant message carrying tool_calls must +// carry reasoning_content byte-equal to the concatenated reasoning deltas. +func TestReplay_AgentReasoningRoundTrip(t *testing.T) { + srv, bodies := serveSSE(t, deepseekThinkingToolCallSSE, cannedStopReplySSE) + provider, err := New(WithBaseURL(srv.URL), WithAPIKey("x")) + require.NoError(t, err) + lm, err := provider.LanguageModel(context.Background(), "deepseek-v4-flash") + require.NoError(t, err) + + var invocations []string + getDate := fantasy.NewAgentTool( + "get_date", + "Get the current date.", + func(_ context.Context, _ struct{}, _ fantasy.ToolCall) (fantasy.ToolResponse, error) { + invocations = append(invocations, "get_date") + return fantasy.NewTextResponse("2026-08-28"), nil + }, + ) + + agent := fantasy.NewAgent(lm, fantasy.WithTools(getDate)) + result, err := agent.Stream(context.Background(), fantasy.AgentStreamCall{Prompt: "what's the date?"}) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, []string{"get_date"}, invocations) + + require.Len(t, *bodies, 2) + var second struct { + Messages []struct { + Role string `json:"role"` + ReasoningContent *string `json:"reasoning_content"` + ToolCalls []struct { + ID string `json:"id"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + } `json:"tool_calls"` + } `json:"messages"` + } + require.NoError(t, json.Unmarshal((*bodies)[1], &second)) + var assistant *struct { + Role string `json:"role"` + ReasoningContent *string `json:"reasoning_content"` + ToolCalls []struct { + ID string `json:"id"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + } `json:"tool_calls"` + } + for i := range second.Messages { + if second.Messages[i].Role == "assistant" { + assistant = &second.Messages[i] + } + } + require.NotNil(t, assistant, "no assistant message in second request: %s", (*bodies)[1]) + require.NotNil(t, assistant.ReasoningContent, + "reasoning_content missing on the agent's replayed assistant message — DeepSeek 400s this and other hosts loop: %s", (*bodies)[1]) + require.Equal(t, "The user wants the date. I'll call get_date.", *assistant.ReasoningContent) + require.Len(t, assistant.ToolCalls, 1) + require.Equal(t, "call_00_abc", assistant.ToolCalls[0].ID) + require.Equal(t, "{}", assistant.ToolCalls[0].Function.Arguments) +} + +// A.2 at agent level: a reasoning-only response truncated by length. The +// agent must surface the reasoning content in the step even though the +// provider can only close the block on the finish chunk. +func TestReplay_AgentReasoningOnlyTruncated(t *testing.T) { + srv, _ := serveSSE(t, deepseekReasoningOnlyLengthSSE) + provider, err := New(WithBaseURL(srv.URL), WithAPIKey("x")) + require.NoError(t, err) + lm, err := provider.LanguageModel(context.Background(), "deepseek-v4-flash") + require.NoError(t, err) + + agent := fantasy.NewAgent(lm) + result, err := agent.Stream(context.Background(), fantasy.AgentStreamCall{Prompt: "think"}) + require.NoError(t, err) + require.NotNil(t, result) + require.NotEmpty(t, result.Steps) + var reasoning string + for _, c := range result.Steps[0].Content { + if rc, ok := fantasy.AsContentType[fantasy.ReasoningContent](c); ok { + reasoning += rc.Text + } + } + require.Equal(t, "Thinking about it at length…", reasoning) + require.Equal(t, fantasy.FinishReasonLength, result.Steps[0].FinishReason) +} + +// A chunk carrying two choices must not duplicate reasoning events: +// StreamExtraFunc is invoked per choice by the openai language model but +// iterates all choices itself, so without care each choice's reasoning is +// emitted once per choice. +const multiChoiceSSE = `{"id":"x","created":1,"model":"m","choices":[{"index":0,"delta":{"reasoning_content":"a"},"finish_reason":null},{"index":1,"delta":{"reasoning_content":"b"},"finish_reason":null}]} +{"id":"x","created":1,"model":"m","choices":[{"index":0,"delta":{"content":"A"},"finish_reason":null},{"index":1,"delta":{"content":"B"},"finish_reason":null}]} +{"id":"x","created":1,"model":"m","choices":[{"index":0,"delta":{},"finish_reason":"stop"},{"index":1,"delta":{},"finish_reason":"stop"}]}` + +func TestReplay_MultiChoiceReasoningNotDuplicated(t *testing.T) { + srv, _ := serveSSE(t, multiChoiceSSE) + provider, err := New(WithBaseURL(srv.URL), WithAPIKey("x")) + require.NoError(t, err) + lm, err := provider.LanguageModel(context.Background(), "m") + require.NoError(t, err) + + parts := streamParts(t, lm, fantasy.Prompt{ + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{fantasy.TextPart{Text: "hi"}}}, + }) + deltasByID := map[string]string{} + startsByID := map[string]int{} + for _, p := range parts { + switch p.Type { + case fantasy.StreamPartTypeReasoningStart: + startsByID[p.ID]++ + case fantasy.StreamPartTypeReasoningDelta: + deltasByID[p.ID] += p.Delta + } + } + require.Equal(t, map[string]int{"0": 1, "1": 1}, startsByID, "types: %v", partTypes(parts)) + require.Equal(t, map[string]string{"0": "a", "1": "b"}, deltasByID, "types: %v", partTypes(parts)) +} + +// A stream that ends cleanly ([DONE]) but never sent a finish_reason must +// not dispatch tool calls whose arguments are incomplete: "tool calls were +// seen" is not proof of a complete turn. Valid arguments keep today's +// inferred tool-call turn, with a warning. +const doneNoFinishTruncatedSSE = `{"id":"x","created":1,"model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"c1","type":"function","function":{"name":"f","arguments":"{\"pa"}}]},"finish_reason":null}]}` + +const doneNoFinishValidSSE = `{"id":"x","created":1,"model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"c1","type":"function","function":{"name":"f","arguments":"{}"}}]},"finish_reason":null}]}` + +func TestReplay_DoneWithoutFinishReason_TruncatedArgsSuppressed(t *testing.T) { + srv, _ := serveSSE(t, doneNoFinishTruncatedSSE) + provider, err := New(WithBaseURL(srv.URL), WithAPIKey("x")) + require.NoError(t, err) + lm, err := provider.LanguageModel(context.Background(), "m") + require.NoError(t, err) + + parts := streamParts(t, lm, fantasy.Prompt{ + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{fantasy.TextPart{Text: "x"}}}, + }) + require.Equal(t, 0, countType(parts, fantasy.StreamPartTypeToolCall), + "truncated tool call must not be dispatched; types: %v", partTypes(parts)) + var sawError bool + for _, p := range parts { + if p.Type == fantasy.StreamPartTypeError { + sawError = true + } + } + require.True(t, sawError, "expected an IncompleteStreamError part, types: %v", partTypes(parts)) + require.Equal(t, 0, countType(parts, fantasy.StreamPartTypeFinish), "types: %v", partTypes(parts)) +} + +func TestReplay_DoneWithoutFinishReason_ValidArgsKeptWithWarning(t *testing.T) { + srv, _ := serveSSE(t, doneNoFinishValidSSE) + provider, err := New(WithBaseURL(srv.URL), WithAPIKey("x")) + require.NoError(t, err) + lm, err := provider.LanguageModel(context.Background(), "m") + require.NoError(t, err) + + parts := streamParts(t, lm, fantasy.Prompt{ + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{fantasy.TextPart{Text: "x"}}}, + }) + require.Equal(t, 1, countType(parts, fantasy.StreamPartTypeToolCall), "types: %v", partTypes(parts)) + var sawWarning bool + for _, p := range parts { + if p.Type == fantasy.StreamPartTypeWarnings { + sawWarning = true + } + } + require.True(t, sawWarning, "expected a CallWarning about the missing finish_reason, types: %v", partTypes(parts)) + var finish *fantasy.StreamPart + for i := range parts { + if parts[i].Type == fantasy.StreamPartTypeFinish { + finish = &parts[i] + } + } + require.NotNil(t, finish) + require.Equal(t, fantasy.FinishReasonToolCalls, finish.FinishReason) +} + +// insufficient_system_resource is a provider-side failure, not a completed +// turn: it must map to an error finish and suppress any open tool calls. +const insufficientResourceSSE = `{"id":"x","created":1,"model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"c1","type":"function","function":{"name":"f","arguments":"{}"}}]},"finish_reason":null}]} +{"id":"x","created":1,"model":"m","choices":[{"index":0,"delta":{},"finish_reason":"insufficient_system_resource"}]}` + +func TestReplay_InsufficientSystemResource_SuppressesToolCalls(t *testing.T) { + srv, _ := serveSSE(t, insufficientResourceSSE) + provider, err := New(WithBaseURL(srv.URL), WithAPIKey("x")) + require.NoError(t, err) + lm, err := provider.LanguageModel(context.Background(), "m") + require.NoError(t, err) + + parts := streamParts(t, lm, fantasy.Prompt{ + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{fantasy.TextPart{Text: "x"}}}, + }) + require.Equal(t, 0, countType(parts, fantasy.StreamPartTypeToolCall), + "tool calls from a failed upstream must not be dispatched; types: %v", partTypes(parts)) + var finish *fantasy.StreamPart + for i := range parts { + if parts[i].Type == fantasy.StreamPartTypeFinish { + finish = &parts[i] + } + } + require.NotNil(t, finish, "types: %v", partTypes(parts)) + require.NotEqual(t, fantasy.FinishReasonToolCalls, finish.FinishReason) +} + +// Choices may not arrive in slice order; reasoning state and part IDs must +// follow choice.Index, not the slice position within a chunk. +const reorderedChoicesSSE = `{"id":"x","created":1,"model":"m","choices":[{"index":1,"delta":{"reasoning_content":"B1"},"finish_reason":null},{"index":0,"delta":{"reasoning_content":"A1"},"finish_reason":null}]} +{"id":"x","created":1,"model":"m","choices":[{"index":0,"delta":{"reasoning_content":"A2"},"finish_reason":null},{"index":1,"delta":{"reasoning_content":"B2"},"finish_reason":null}]} +{"id":"x","created":1,"model":"m","choices":[{"index":0,"delta":{"content":"a"},"finish_reason":null},{"index":1,"delta":{"content":"b"},"finish_reason":null}]} +{"id":"x","created":1,"model":"m","choices":[{"index":0,"delta":{},"finish_reason":"stop"},{"index":1,"delta":{},"finish_reason":"stop"}]}` + +func TestReplay_ReorderedChoices(t *testing.T) { + srv, _ := serveSSE(t, reorderedChoicesSSE) + provider, err := New(WithBaseURL(srv.URL), WithAPIKey("x")) + require.NoError(t, err) + lm, err := provider.LanguageModel(context.Background(), "m") + require.NoError(t, err) + + parts := streamParts(t, lm, fantasy.Prompt{ + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{fantasy.TextPart{Text: "hi"}}}, + }) + deltasByID := map[string]string{} + endsByID := map[string]int{} + startsByID := map[string]int{} + for _, p := range parts { + switch p.Type { + case fantasy.StreamPartTypeReasoningStart: + startsByID[p.ID]++ + case fantasy.StreamPartTypeReasoningDelta: + deltasByID[p.ID] += p.Delta + case fantasy.StreamPartTypeReasoningEnd: + endsByID[p.ID]++ + } + } + require.Equal(t, map[string]int{"0": 1, "1": 1}, startsByID, "types: %v", partTypes(parts)) + require.Equal(t, map[string]string{"0": "A1A2", "1": "B1B2"}, deltasByID, "types: %v", partTypes(parts)) + require.Equal(t, map[string]int{"0": 1, "1": 1}, endsByID, "types: %v", partTypes(parts)) +} + +// A content_filter finish can cut a tool call mid-arguments; like length, +// it must not be rewritten to tool_calls and its calls must not dispatch. +const contentFilterToolCallSSE = `{"id":"x","created":1,"model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"c1","type":"function","function":{"name":"f","arguments":"{}"}}]},"finish_reason":null}]} +{"id":"x","created":1,"model":"m","choices":[{"index":0,"delta":{},"finish_reason":"content_filter"}]}` + +func TestReplay_ContentFilter_SuppressesToolCalls(t *testing.T) { + srv, _ := serveSSE(t, contentFilterToolCallSSE) + provider, err := New(WithBaseURL(srv.URL), WithAPIKey("x")) + require.NoError(t, err) + lm, err := provider.LanguageModel(context.Background(), "m") + require.NoError(t, err) + + parts := streamParts(t, lm, fantasy.Prompt{ + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{fantasy.TextPart{Text: "x"}}}, + }) + require.Equal(t, 0, countType(parts, fantasy.StreamPartTypeToolCall), + "tool calls from a filtered response must not be dispatched; types: %v", partTypes(parts)) + var finish *fantasy.StreamPart + for i := range parts { + if parts[i].Type == fantasy.StreamPartTypeFinish { + finish = &parts[i] + } + } + require.NotNil(t, finish, "types: %v", partTypes(parts)) + require.Equal(t, fantasy.FinishReasonContentFilter, finish.FinishReason) +} + +// A stream with no finish_reason whose only tool call is a bare +// declaration (no arguments at all) was cut before any argument arrived. +// Inferring a complete "{}" call invents arguments the model never sent. +const doneNoFinishNoArgsSSE = `{"id":"x","created":1,"model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"c1","type":"function","function":{"name":"f","arguments":""}}]},"finish_reason":null}]}` + +func TestReplay_DoneWithoutFinishReason_NoArgsSuppressed(t *testing.T) { + srv, _ := serveSSE(t, doneNoFinishNoArgsSSE) + provider, err := New(WithBaseURL(srv.URL), WithAPIKey("x")) + require.NoError(t, err) + lm, err := provider.LanguageModel(context.Background(), "m") + require.NoError(t, err) + + parts := streamParts(t, lm, fantasy.Prompt{ + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{fantasy.TextPart{Text: "x"}}}, + }) + require.Equal(t, 0, countType(parts, fantasy.StreamPartTypeToolCall), + "a bare tool declaration is not a complete call; types: %v", partTypes(parts)) + var sawError bool + for _, p := range parts { + if p.Type == fantasy.StreamPartTypeError { + sawError = true + } + } + require.True(t, sawError, "expected IncompleteStreamError, types: %v", partTypes(parts)) +} + +// Interleaved parallel tool calls: deltas for index 0 continue after index +// 1 has started. Arguments for both must accumulate fully — closing index 0 +// when 1 first appears would drop its trailing deltas. +const interleavedParallelSSE = `{"id":"x","created":1,"model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"c0","type":"function","function":{"name":"grep","arguments":"{\"pat"}}]},"finish_reason":null}]} +{"id":"x","created":1,"model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"id":"c1","type":"function","function":{"name":"glob","arguments":"{\"pa"}}]},"finish_reason":null}]} +{"id":"x","created":1,"model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"tern\":\"foo\"}"}}]},"finish_reason":null}]} +{"id":"x","created":1,"model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"th\":\"x\"}"}}]},"finish_reason":null}]} +{"id":"x","created":1,"model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}` + +func TestReplay_InterleavedParallelToolCalls(t *testing.T) { + srv, _ := serveSSE(t, interleavedParallelSSE) + provider, err := New(WithBaseURL(srv.URL), WithAPIKey("x")) + require.NoError(t, err) + lm, err := provider.LanguageModel(context.Background(), "m") + require.NoError(t, err) + + parts := streamParts(t, lm, fantasy.Prompt{ + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{fantasy.TextPart{Text: "x"}}}, + }) + calls := map[string]string{} + for _, p := range parts { + if p.Type == fantasy.StreamPartTypeToolCall { + calls[p.ToolCallName] = p.ToolCallInput + } + } + require.Equal(t, map[string]string{ + "grep": `{"pattern":"foo"}`, + "glob": `{"path":"x"}`, + }, calls) +} + +// On a cut stream, consumers must not see a ToolInputEnd that presents +// fabricated "{}" input for a call that never sent arguments. +func TestReplay_DoneWithoutFinishReason_NoFabricatedEnd(t *testing.T) { + srv, _ := serveSSE(t, doneNoFinishNoArgsSSE) + provider, err := New(WithBaseURL(srv.URL), WithAPIKey("x")) + require.NoError(t, err) + lm, err := provider.LanguageModel(context.Background(), "m") + require.NoError(t, err) + + parts := streamParts(t, lm, fantasy.Prompt{ + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{fantasy.TextPart{Text: "x"}}}, + }) + for _, p := range parts { + require.NotEqual(t, fantasy.StreamPartTypeToolInputEnd, p.Type, + "no ToolInputEnd may be emitted on a cut stream; types: %v", partTypes(parts)) + require.NotEqual(t, fantasy.StreamPartTypeToolCall, p.Type, "types: %v", partTypes(parts)) + } + var sawError bool + for _, p := range parts { + if p.Type == fantasy.StreamPartTypeError { + sawError = true + } + } + require.True(t, sawError, "types: %v", partTypes(parts)) +} diff --git a/providers/openaicompat/strict_upstream_test.go b/providers/openaicompat/strict_upstream_test.go new file mode 100644 index 000000000..3f702b6fd --- /dev/null +++ b/providers/openaicompat/strict_upstream_test.go @@ -0,0 +1,243 @@ +package openaicompat + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "charm.land/fantasy" + "github.com/stretchr/testify/require" +) + +// This file pins the DeepSeek/Kimi replay contract against a strict upstream +// double. Unlike serveSSE, which records bodies for after-the-fact +// assertions, serveStrictDeepSeek enforces the two documented rejection rules +// as hard 400s, the way the real strict servers do: +// +// 1. An assistant message carrying neither content nor tool calls is +// rejected ("Invalid assistant message: content or tool_calls must be +// set"). Sending one bricks every later request, which is exactly what +// charmbracelet/crush#3794 and #3788 reported. +// 2. When the request carries tools, an assistant tool-call turn without a +// reasoning_content field is rejected ("The reasoning_content in the +// thinking mode must be passed back to the API"), per DeepSeek's +// thinking-mode guide; dropping it is what made models loop in +// charmbracelet/crush#2696. +// +// A client that satisfies both rules can drive multi-step tool loops and +// replay canceled-mid-thinking history without poisoning the session. + +// strictMessage mirrors one inbound messages[] entry with raw fields so the +// validator can distinguish absent/null/empty from a real value. +type strictMessage struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + ToolCalls json.RawMessage `json:"tool_calls"` + ReasoningContent json.RawMessage `json:"reasoning_content"` +} + +type strictRequest struct { + Stream bool `json:"stream"` + Tools json.RawMessage `json:"tools"` + Messages []strictMessage `json:"messages"` +} + +// hasContent reports whether the message carries user-visible content: a +// non-empty string or a non-empty array of content parts. +func (m strictMessage) hasContent() bool { + raw := strings.TrimSpace(string(m.Content)) + if raw == "" || raw == "null" { + return false + } + var s string + if err := json.Unmarshal(m.Content, &s); err == nil { + return s != "" + } + var parts []json.RawMessage + if err := json.Unmarshal(m.Content, &parts); err == nil { + return len(parts) > 0 + } + return true +} + +func (m strictMessage) hasToolCalls() bool { + raw := strings.TrimSpace(string(m.ToolCalls)) + if raw == "" || raw == "null" { + return false + } + var calls []json.RawMessage + if err := json.Unmarshal(m.ToolCalls, &calls); err != nil { + return false + } + return len(calls) > 0 +} + +// hasReasoningField reports whether reasoning_content is present and not +// null. DeepSeek 400s a tool-call turn when the key is missing; a null means +// "no reasoning in this message" and is not accepted as replay. +func (m strictMessage) hasReasoningField() bool { + raw := strings.TrimSpace(string(m.ReasoningContent)) + return raw != "" && raw != "null" +} + +func strictError(w http.ResponseWriter, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "message": message, + "type": "invalid_request_error", + "code": "invalid_request_error", + }, + }) +} + +// serveStrictDeepSeek returns an httptest server that validates every inbound +// request against the two contract rules above (answering 400 on violation) +// and otherwise behaves like serveSSE: streaming POSTs get the given SSE +// payloads in order, non-streaming POSTs get a canned completion. Every +// request body is recorded. +func serveStrictDeepSeek(t *testing.T, payloads ...string) (*httptest.Server, *[][]byte) { + t.Helper() + var bodies [][]byte + var calls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + bodies = append(bodies, body) + + var req strictRequest + if err := json.Unmarshal(body, &req); err != nil { + strictError(w, "bad request: openai_error") + return + } + + toolsPresent := len(strings.TrimSpace(string(req.Tools))) > 0 && + string(strings.TrimSpace(string(req.Tools))) != "null" + for _, m := range req.Messages { + if m.Role != "assistant" { + continue + } + if !m.hasContent() && !m.hasToolCalls() { + strictError(w, "Invalid assistant message: content or tool_calls must be set") + return + } + if toolsPresent && m.hasToolCalls() && !m.hasReasoningField() { + strictError(w, "The reasoning_content in the thinking mode must be passed back to the API") + return + } + } + + if !req.Stream { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{"id":"y","created":1,"model":"deepseek-v4-flash","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"The date is 2026-08-28."},"finish_reason":"stop"}],"usage":{"prompt_tokens":200,"completion_tokens":10,"total_tokens":210}}`) + return + } + + payload := payloads[min(calls, len(payloads)-1)] + calls++ + + w.Header().Set("Content-Type", "text/event-stream") + rc := http.NewResponseController(w) + for event := range strings.Lines(payload) { + if strings.TrimSpace(event) == "" { + continue + } + fmt.Fprintf(w, "data: %s\n\n", strings.TrimSpace(event)) + _ = rc.Flush() + } + _, _ = fmt.Fprint(w, "data: [DONE]\n\n") + _ = rc.Flush() + })) + t.Cleanup(srv.Close) + return srv, &bodies +} + +// A multi-step tool loop against the strict upstream: thinking + tool call on +// step one, plain answer on step two. The server hard-validates both rules on +// every request, so the loop completing at all proves the reasoning replay +// contract held (charmbracelet/crush#2696) and no empty assistant message was +// ever emitted (charmbracelet/crush#3794). +func TestStrictUpstream_ToolCallLoop(t *testing.T) { + srv, bodies := serveStrictDeepSeek(t, deepseekThinkingToolCallSSE, cannedStopReplySSE) + provider, err := New(WithBaseURL(srv.URL), WithAPIKey("x")) + require.NoError(t, err) + lm, err := provider.LanguageModel(context.Background(), "deepseek-v4-flash") + require.NoError(t, err) + + var invocations []string + getDate := fantasy.NewAgentTool( + "get_date", + "Get the current date.", + func(_ context.Context, _ struct{}, _ fantasy.ToolCall) (fantasy.ToolResponse, error) { + invocations = append(invocations, "get_date") + return fantasy.NewTextResponse("2026-08-28"), nil + }, + ) + + agent := fantasy.NewAgent(lm, fantasy.WithTools(getDate)) + result, err := agent.Stream(context.Background(), fantasy.AgentStreamCall{Prompt: "what's the date?"}) + require.NoError(t, err, "the strict upstream rejected a request; the loop must run clean") + require.NotNil(t, result) + require.Equal(t, []string{"get_date"}, invocations) + + // Two steps: the tool-call turn and the final answer. The second request + // is the replay: the tool-call assistant message must carry the + // concatenated reasoning byte-for-byte. + require.Len(t, *bodies, 2) + var second strictRequest + require.NoError(t, json.Unmarshal((*bodies)[1], &second)) + var assistant *strictMessage + for i := range second.Messages { + if second.Messages[i].Role == "assistant" { + assistant = &second.Messages[i] + } + } + require.NotNil(t, assistant, "no assistant message in second request: %s", (*bodies)[1]) + require.JSONEq(t, `"The user wants the date. I'll call get_date."`, string(assistant.ReasoningContent), + "reasoning_content must round-trip byte-for-byte") +} + +// A turn canceled while the model was still thinking lands in history as an +// assistant message whose only part is reasoning (charmbracelet/crush#3794, +// #3788). Replayed on the next turn it must be dropped, not sent: the strict +// upstream 400s the empty shape, and once it is in history every later +// request fails the same way. A completed tool-call turn in the same history +// must keep its reasoning_content. +func TestStrictUpstream_CanceledThinkingTurnReplayed(t *testing.T) { + srv, bodies := serveStrictDeepSeek(t, cannedStopReplySSE) + provider, err := New(WithBaseURL(srv.URL), WithAPIKey("x")) + require.NoError(t, err) + lm, err := provider.LanguageModel(context.Background(), "deepseek-v4-flash") + require.NoError(t, err) + + prompt := fantasy.Prompt{ + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{fantasy.TextPart{Text: "hi"}}}, + {Role: fantasy.MessageRoleAssistant, Content: []fantasy.MessagePart{ + fantasy.ReasoningPart{Text: "Thinking out loud, then the user hit escape…"}, + }}, + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{fantasy.TextPart{Text: "are you there?"}}}, + } + + parts := streamParts(t, lm, prompt) + require.Zero(t, countType(parts, fantasy.StreamPartTypeError), + "stream errored; the strict upstream 400s the reasoning-only replay shape") + require.NotZero(t, countType(parts, fantasy.StreamPartTypeFinish), + "stream must finish; the strict upstream 400s the reasoning-only replay shape") + + require.Len(t, *bodies, 1) + var req strictRequest + require.NoError(t, json.Unmarshal((*bodies)[0], &req)) + for i, m := range req.Messages { + if m.Role != "assistant" { + continue + } + require.True(t, m.hasContent() || m.hasToolCalls(), + "assistant message %d went out with neither content nor tool calls: %s", i, (*bodies)[0]) + } +} diff --git a/providers/openrouter/language_model_hooks.go b/providers/openrouter/language_model_hooks.go index 8cc4d3407..d9accd2c2 100644 --- a/providers/openrouter/language_model_hooks.go +++ b/providers/openrouter/language_model_hooks.go @@ -457,11 +457,16 @@ func languageModelUsage(response openaisdk.ChatCompletion) (fantasy.Usage, fanta // OpenRouter reports prompt_tokens INCLUDING cached tokens. Subtract to avoid double-counting. inputTokens := max(usage.PromptTokens-promptTokenDetails.CachedTokens, 0) + outputTokens, totalTokens := openai.FoldDisjointReasoning( + usage.CompletionTokens, + completionTokenDetails.ReasoningTokens, + inputTokens+usage.CompletionTokens+promptTokenDetails.CachedTokens, + ) return fantasy.Usage{ InputTokens: inputTokens, - OutputTokens: usage.CompletionTokens, - TotalTokens: inputTokens + usage.CompletionTokens + promptTokenDetails.CachedTokens, + OutputTokens: outputTokens, + TotalTokens: totalTokens, ReasoningTokens: completionTokenDetails.ReasoningTokens, CacheReadTokens: promptTokenDetails.CachedTokens, }, providerMetadata @@ -496,11 +501,16 @@ func languageModelStreamUsage(chunk openaisdk.ChatCompletionChunk, _ map[string] // OpenRouter reports prompt_tokens INCLUDING cached tokens. Subtract to avoid double-counting. inputTokens := max(usage.PromptTokens-promptTokenDetails.CachedTokens, 0) + outputTokens, totalTokens := openai.FoldDisjointReasoning( + usage.CompletionTokens, + completionTokenDetails.ReasoningTokens, + inputTokens+usage.CompletionTokens+promptTokenDetails.CachedTokens, + ) aiUsage := fantasy.Usage{ InputTokens: inputTokens, - OutputTokens: usage.CompletionTokens, - TotalTokens: inputTokens + usage.CompletionTokens + promptTokenDetails.CachedTokens, + OutputTokens: outputTokens, + TotalTokens: totalTokens, ReasoningTokens: completionTokenDetails.ReasoningTokens, CacheReadTokens: promptTokenDetails.CachedTokens, } @@ -513,7 +523,9 @@ func languageModelStreamUsage(chunk openaisdk.ChatCompletionChunk, _ map[string] func languageModelToPrompt(prompt fantasy.Prompt, _, model string) ([]openaisdk.ChatCompletionMessageParamUnion, []fantasy.CallWarning) { var messages []openaisdk.ChatCompletionMessageParamUnion var warnings []fantasy.CallWarning + var media openai.ToolRunBuffer for _, msg := range prompt { + messages = media.Role(msg.Role, messages) switch msg.Role { case fantasy.MessageRoleSystem: var systemPromptParts []string @@ -738,6 +750,13 @@ func languageModelToPrompt(prompt fantasy.Prompt, _, model string) ([]openaisdk. } } } + if !openai.HasVisibleUserContent(content) { + warnings = append(warnings, fantasy.CallWarning{ + Type: fantasy.CallWarningTypeOther, + Message: "dropping empty user message (contains neither user-facing content nor tool results)", + }) + continue + } messages = append(messages, openaisdk.UserMessage(content)) case fantasy.MessageRoleAssistant: // simple assistant message just text content @@ -990,74 +1009,22 @@ func languageModelToPrompt(prompt fantasy.Prompt, _, model string) ([]openaisdk. assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, tc) } } + if !openai.HasVisibleAssistantContent(&assistantMsg) { + warnings = append(warnings, fantasy.CallWarning{ + Type: fantasy.CallWarningTypeOther, + Message: "dropping empty assistant message (contains neither user-facing content nor tool calls)", + }) + continue + } messages = append(messages, openaisdk.ChatCompletionMessageParamUnion{ OfAssistant: &assistantMsg, }) case fantasy.MessageRoleTool: - for i, c := range msg.Content { - isLastPart := i == len(msg.Content)-1 - cacheControl := anthropic.GetCacheControl(c.Options()) - if cacheControl == nil && isLastPart { - cacheControl = anthropic.GetCacheControl(msg.ProviderOptions) - } - if c.GetType() != fantasy.ContentTypeToolResult { - warnings = append(warnings, fantasy.CallWarning{ - Type: fantasy.CallWarningTypeOther, - Message: "tool message can only have tool result content", - }) - continue - } - - toolResultPart, ok := fantasy.AsContentType[fantasy.ToolResultPart](c) - if !ok { - warnings = append(warnings, fantasy.CallWarning{ - Type: fantasy.CallWarningTypeOther, - Message: "tool message result part does not have the right type", - }) - continue - } - - switch toolResultPart.Output.GetType() { - case fantasy.ToolResultContentTypeText: - output, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentText](toolResultPart.Output) - if !ok { - warnings = append(warnings, fantasy.CallWarning{ - Type: fantasy.CallWarningTypeOther, - Message: "tool result output does not have the right type", - }) - continue - } - tr := openaisdk.ToolMessage(output.Text, toolResultPart.ToolCallID) - if cacheControl != nil { - tr.SetExtraFields(map[string]any{ - "cache_control": map[string]string{ - "type": cacheControl.Type, - }, - }) - } - messages = append(messages, tr) - case fantasy.ToolResultContentTypeError: - // TODO: check if better handling is needed - output, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentError](toolResultPart.Output) - if !ok { - warnings = append(warnings, fantasy.CallWarning{ - Type: fantasy.CallWarningTypeOther, - Message: "tool result output does not have the right type", - }) - continue - } - tr := openaisdk.ToolMessage(output.Error.Error(), toolResultPart.ToolCallID) - if cacheControl != nil { - tr.SetExtraFields(map[string]any{ - "cache_control": map[string]string{ - "type": cacheControl.Type, - }, - }) - } - messages = append(messages, tr) - } - } + toolMessages, deferred, toolWarnings := openai.ToolMessages(msg, anthropic.ToolResultCacheType) + messages = append(messages, toolMessages...) + media.Defer(deferred...) + warnings = append(warnings, toolWarnings...) } } - return messages, warnings + return media.Close(messages), warnings } diff --git a/providers/openrouter/prompt.go b/providers/openrouter/prompt.go new file mode 100644 index 000000000..e669741c8 --- /dev/null +++ b/providers/openrouter/prompt.go @@ -0,0 +1,7 @@ +package openrouter + +import "charm.land/fantasy/providers/openai" + +// ToPrompt is the prompt converter this provider installs on its language +// models, exported for the shared corpus in providers/internal/prompttest. +var ToPrompt openai.LanguageModelToPromptFunc = languageModelToPrompt diff --git a/providers/vercel/language_model_hooks.go b/providers/vercel/language_model_hooks.go index 3361ecf4d..79e809ac0 100644 --- a/providers/vercel/language_model_hooks.go +++ b/providers/vercel/language_model_hooks.go @@ -485,6 +485,17 @@ func languageModelStreamExtra(chunk openaisdk.ChatCompletionChunk, yield func(fa return ctx, true } +// isVertexUpstream reports whether Vercel's upstream for the request was +// Vertex AI. Vercel forwards Vertex's usage verbatim, and Vertex reports +// thoughts disjoint from completion_tokens — unlike OpenAI's subset shape — +// so reasoning must be folded into output even when it does not exceed it +// (the numeric guard in FoldDisjointReasoning only proves disjointness when +// reasoning > output). The provider value arrives as raw JSON, quotes and +// all. +func isVertexUpstream(provider string) bool { + return strings.Trim(provider, `"`) == "vertex" +} + func languageModelUsage(response openaisdk.ChatCompletion) (fantasy.Usage, fantasy.ProviderOptionsData) { if len(response.Choices) == 0 { return fantasy.Usage{}, nil @@ -505,12 +516,21 @@ func languageModelUsage(response openaisdk.ChatCompletion) (fantasy.Usage, fanta // Vercel reports prompt_tokens INCLUDING cached tokens. Subtract to avoid double-counting. inputTokens := max(usage.PromptTokens-promptTokenDetails.CachedTokens, 0) + reasoning := completionTokenDetails.ReasoningTokens + outputTokens := usage.CompletionTokens + totalTokens := inputTokens + usage.CompletionTokens + promptTokenDetails.CachedTokens + if isVertexUpstream(provider) { + outputTokens += reasoning + totalTokens += reasoning + } else { + outputTokens, totalTokens = openaipkg.FoldDisjointReasoning(outputTokens, reasoning, totalTokens) + } return fantasy.Usage{ InputTokens: inputTokens, - OutputTokens: usage.CompletionTokens, - TotalTokens: inputTokens + usage.CompletionTokens + promptTokenDetails.CachedTokens, - ReasoningTokens: completionTokenDetails.ReasoningTokens, + OutputTokens: outputTokens, + TotalTokens: totalTokens, + ReasoningTokens: reasoning, CacheReadTokens: promptTokenDetails.CachedTokens, }, providerMetadata } @@ -531,8 +551,10 @@ func languageModelStreamUsage(chunk openaisdk.ChatCompletionChunk, _ map[string] } } + var provider string if p, ok := chunk.JSON.ExtraFields["provider"]; ok { - streamProviderMetadata.Provider = p.Raw() + provider = p.Raw() + streamProviderMetadata.Provider = provider } completionTokenDetails := usage.CompletionTokensDetails @@ -540,12 +562,21 @@ func languageModelStreamUsage(chunk openaisdk.ChatCompletionChunk, _ map[string] // Vercel reports prompt_tokens INCLUDING cached tokens. Subtract to avoid double-counting. inputTokens := max(usage.PromptTokens-promptTokenDetails.CachedTokens, 0) + reasoning := completionTokenDetails.ReasoningTokens + outputTokens := usage.CompletionTokens + totalTokens := inputTokens + usage.CompletionTokens + promptTokenDetails.CachedTokens + if isVertexUpstream(provider) { + outputTokens += reasoning + totalTokens += reasoning + } else { + outputTokens, totalTokens = openaipkg.FoldDisjointReasoning(outputTokens, reasoning, totalTokens) + } aiUsage := fantasy.Usage{ InputTokens: inputTokens, - OutputTokens: usage.CompletionTokens, - TotalTokens: inputTokens + usage.CompletionTokens + promptTokenDetails.CachedTokens, - ReasoningTokens: completionTokenDetails.ReasoningTokens, + OutputTokens: outputTokens, + TotalTokens: totalTokens, + ReasoningTokens: reasoning, CacheReadTokens: promptTokenDetails.CachedTokens, } @@ -557,8 +588,10 @@ func languageModelStreamUsage(chunk openaisdk.ChatCompletionChunk, _ map[string] func languageModelToPrompt(prompt fantasy.Prompt, _, model string) ([]openaisdk.ChatCompletionMessageParamUnion, []fantasy.CallWarning) { var messages []openaisdk.ChatCompletionMessageParamUnion var warnings []fantasy.CallWarning + var media openaipkg.ToolRunBuffer for _, msg := range prompt { + messages = media.Role(msg.Role, messages) switch msg.Role { case fantasy.MessageRoleSystem: var systemPromptParts []string @@ -764,7 +797,7 @@ func languageModelToPrompt(prompt fantasy.Prompt, _, model string) ([]openaisdk. } } } - if !hasVisibleUserContent(content) { + if !openaipkg.HasVisibleUserContent(content) { warnings = append(warnings, fantasy.CallWarning{ Type: fantasy.CallWarningTypeOther, Message: "dropping empty user message (contains neither user-facing content nor tool results)", @@ -981,83 +1014,25 @@ func languageModelToPrompt(prompt fantasy.Prompt, _, model string) ([]openaisdk. assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, tc) } } + if !openaipkg.HasVisibleAssistantContent(&assistantMsg) { + warnings = append(warnings, fantasy.CallWarning{ + Type: fantasy.CallWarningTypeOther, + Message: "dropping empty assistant message (contains neither user-facing content nor tool calls)", + }) + continue + } messages = append(messages, openaisdk.ChatCompletionMessageParamUnion{ OfAssistant: &assistantMsg, }) case fantasy.MessageRoleTool: - for i, c := range msg.Content { - isLastPart := i == len(msg.Content)-1 - cacheControl := anthropic.GetCacheControl(c.Options()) - if cacheControl == nil && isLastPart { - cacheControl = anthropic.GetCacheControl(msg.ProviderOptions) - } - if c.GetType() != fantasy.ContentTypeToolResult { - warnings = append(warnings, fantasy.CallWarning{ - Type: fantasy.CallWarningTypeOther, - Message: "tool message can only have tool result content", - }) - continue - } - toolResultPart, ok := fantasy.AsContentType[fantasy.ToolResultPart](c) - if !ok { - warnings = append(warnings, fantasy.CallWarning{ - Type: fantasy.CallWarningTypeOther, - Message: "tool message result part does not have the right type", - }) - continue - } - switch toolResultPart.Output.GetType() { - case fantasy.ToolResultContentTypeText: - output, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentText](toolResultPart.Output) - if !ok { - warnings = append(warnings, fantasy.CallWarning{ - Type: fantasy.CallWarningTypeOther, - Message: "tool result output does not have the right type", - }) - continue - } - tr := openaisdk.ToolMessage(output.Text, toolResultPart.ToolCallID) - if cacheControl != nil { - tr.SetExtraFields(map[string]any{ - "cache_control": map[string]string{ - "type": cacheControl.Type, - }, - }) - } - messages = append(messages, tr) - case fantasy.ToolResultContentTypeError: - output, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentError](toolResultPart.Output) - if !ok { - warnings = append(warnings, fantasy.CallWarning{ - Type: fantasy.CallWarningTypeOther, - Message: "tool result output does not have the right type", - }) - continue - } - tr := openaisdk.ToolMessage(output.Error.Error(), toolResultPart.ToolCallID) - if cacheControl != nil { - tr.SetExtraFields(map[string]any{ - "cache_control": map[string]string{ - "type": cacheControl.Type, - }, - }) - } - messages = append(messages, tr) - } - } - } - } - return messages, warnings -} - -func hasVisibleUserContent(content []openaisdk.ChatCompletionContentPartUnionParam) bool { - for _, part := range content { - if part.OfText != nil || part.OfImageURL != nil || part.OfInputAudio != nil || part.OfFile != nil { - return true + toolMessages, deferred, toolWarnings := openaipkg.ToolMessages(msg, anthropic.ToolResultCacheType) + messages = append(messages, toolMessages...) + media.Defer(deferred...) + warnings = append(warnings, toolWarnings...) } } - return false + return media.Close(messages), warnings } func structToMapJSON(s any) (map[string]any, error) { diff --git a/providers/vercel/language_model_hooks_test.go b/providers/vercel/language_model_hooks_test.go new file mode 100644 index 000000000..575caacb6 --- /dev/null +++ b/providers/vercel/language_model_hooks_test.go @@ -0,0 +1,21 @@ +package vercel + +import "testing" + +func TestIsVertexUpstream(t *testing.T) { + tests := []struct { + provider string + want bool + }{ + {`"vertex"`, true}, + {`"google"`, false}, + {`"openai"`, false}, + {"", false}, + {"vertex", true}, // defensive: already-unquoted value + } + for _, tc := range tests { + if got := isVertexUpstream(tc.provider); got != tc.want { + t.Errorf("isVertexUpstream(%q) = %v, want %v", tc.provider, got, tc.want) + } + } +} diff --git a/providers/vercel/prompt.go b/providers/vercel/prompt.go new file mode 100644 index 000000000..a8d2b287c --- /dev/null +++ b/providers/vercel/prompt.go @@ -0,0 +1,7 @@ +package vercel + +import openaipkg "charm.land/fantasy/providers/openai" + +// ToPrompt is the prompt converter this provider installs on its language +// models, exported for the shared corpus in providers/internal/prompttest. +var ToPrompt openaipkg.LanguageModelToPromptFunc = languageModelToPrompt diff --git a/providertests/anthropic_test.go b/providertests/anthropic_test.go index f0ee72667..91a7a6b71 100644 --- a/providertests/anthropic_test.go +++ b/providertests/anthropic_test.go @@ -1,6 +1,7 @@ package providertests import ( + "cmp" "context" "encoding/json" "net/http" @@ -147,7 +148,7 @@ func testAnthropicThinking(t *testing.T, result *fantasy.AgentResult) { func anthropicBuilder(model string) builderFunc { return func(t *testing.T, r *vcr.Recorder) (fantasy.LanguageModel, error) { provider, err := anthropic.New( - anthropic.WithAPIKey(os.Getenv("FANTASY_ANTHROPIC_API_KEY")), + anthropic.WithAPIKey(cmp.Or(os.Getenv("FANTASY_ANTHROPIC_API_KEY"), "(missing)")), anthropic.WithHTTPClient(&http.Client{Transport: r}), ) if err != nil { diff --git a/providertests/common_test.go b/providertests/common_test.go index f13c93a92..8a691a534 100644 --- a/providertests/common_test.go +++ b/providertests/common_test.go @@ -327,6 +327,7 @@ func testThinking(t *testing.T, pairs []builderPair, thinkChecks func(*testing.T got := result.Response.Content.Text() require.True(t, strings.Contains(got, want1) && strings.Contains(got, want2), "unexpected response: got %q, want %q %q", got, want1, want2) + assertReasoningSubsetOfOutput(t, result.TotalUsage) thinkChecks(t, result) }) @@ -365,12 +366,24 @@ func testThinking(t *testing.T, pairs []builderPair, thinkChecks func(*testing.T got := result.Response.Content.Text() require.True(t, strings.Contains(got, want1) && strings.Contains(got, want2), "unexpected response: got %q, want %q %q", got, want1, want2) + assertReasoningSubsetOfOutput(t, result.TotalUsage) thinkChecks(t, result) }) }) } } +// assertReasoningSubsetOfOutput pins the RFC 0020 invariant: reasoning tokens +// are a subset of output tokens, never disjoint addends. A provider whose +// usage mapping violates this double-counts reasoning downstream. +func assertReasoningSubsetOfOutput(t *testing.T, usage fantasy.Usage) { + t.Helper() + if usage.ReasoningTokens > 0 { + require.GreaterOrEqual(t, usage.OutputTokens, usage.ReasoningTokens, + "reasoning tokens must be a subset of output tokens") + } +} + func containsAny(s string, subs ...string) bool { for _, sub := range subs { if strings.Contains(s, sub) { diff --git a/providertests/testdata/TestOpenAICompatibleCommon/avian-kimi/multi_tool_streaming.yaml b/providertests/testdata/TestOpenAICompatibleCommon/avian-kimi/multi_tool_streaming.yaml index 209451f94..3c627f93e 100644 --- a/providertests/testdata/TestOpenAICompatibleCommon/avian-kimi/multi_tool_streaming.yaml +++ b/providertests/testdata/TestOpenAICompatibleCommon/avian-kimi/multi_tool_streaming.yaml @@ -15,7 +15,7 @@ interactions: Content-Type: - application/json User-Agent: - - Charm-Fantasy/0.15.0 (https://charm.land/fantasy) + - Charm-Fantasy/0.41.3 (https://charm.land/fantasy) url: https://api.avian.io/v1/chat/completions method: POST response: @@ -24,129 +24,152 @@ interactions: proto_minor: 0 content_length: -1 body: |+ - data: {"id": "chatcmpl-ec08ee4b2cd84124b8f4f469", "object": "chat.completion.chunk", "created": 1773932885, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": null}]} + data: {"id": "chatcmpl-88a2d88ed6b940f3a0d705b4", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "The", "format": "unknown", "index": 0}], "reasoning_content": "The"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " user wants", "format": "unknown", "index": 0}], "reasoning_content": " user wants"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "The", "format": "unknown", "index": 0}], "reasoning_content": "The"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " me to", "format": "unknown", "index": 0}], "reasoning_content": " me to"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " user wants", "format": "unknown", "index": 0}], "reasoning_content": " user wants"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " add and", "format": "unknown", "index": 0}], "reasoning_content": " add and"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " me to", "format": "unknown", "index": 0}], "reasoning_content": " me to"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " multiply the", "format": "unknown", "index": 0}], "reasoning_content": " multiply the"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " add and", "format": "unknown", "index": 0}], "reasoning_content": " add and"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " numbers ", "format": "unknown", "index": 0}], "reasoning_content": " numbers "}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " multiply the", "format": "unknown", "index": 0}], "reasoning_content": " multiply the"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "2 and", "format": "unknown", "index": 0}], "reasoning_content": "2 and"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " numbers ", "format": "unknown", "index": 0}], "reasoning_content": " numbers "}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " 3", "format": "unknown", "index": 0}], "reasoning_content": " 3"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "2 and", "format": "unknown", "index": 0}], "reasoning_content": "2 and"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ". The", "format": "unknown", "index": 0}], "reasoning_content": ". The"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " 3", "format": "unknown", "index": 0}], "reasoning_content": " 3"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " instruction", "format": "unknown", "index": 0}], "reasoning_content": " instruction"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ". According", "format": "unknown", "index": 0}], "reasoning_content": ". According"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " says \"", "format": "unknown", "index": 0}], "reasoning_content": " says \""}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " to my", "format": "unknown", "index": 0}], "reasoning_content": " to my"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "Always use", "format": "unknown", "index": 0}], "reasoning_content": "Always use"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " instructions,", "format": "unknown", "index": 0}], "reasoning_content": " instructions,"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " both add", "format": "unknown", "index": 0}], "reasoning_content": " both add"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " I should", "format": "unknown", "index": 0}], "reasoning_content": " I should"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " and multiply", "format": "unknown", "index": 0}], "reasoning_content": " and multiply"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " always use", "format": "unknown", "index": 0}], "reasoning_content": " always use"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " at the", "format": "unknown", "index": 0}], "reasoning_content": " at the"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " both add", "format": "unknown", "index": 0}], "reasoning_content": " both add"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " same time", "format": "unknown", "index": 0}], "reasoning_content": " same time"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " and multiply", "format": "unknown", "index": 0}], "reasoning_content": " and multiply"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ".\"\n\n", "format": "unknown", "index": 0}], "reasoning_content": ".\"\n\n"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " at the", "format": "unknown", "index": 0}], "reasoning_content": " at the"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "So", "format": "unknown", "index": 0}], "reasoning_content": "So"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " same time", "format": "unknown", "index": 0}], "reasoning_content": " same time"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " I need", "format": "unknown", "index": 0}], "reasoning_content": " I need"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " (", "format": "unknown", "index": 0}], "reasoning_content": " ("}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " to call", "format": "unknown", "index": 0}], "reasoning_content": " to call"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "which", "format": "unknown", "index": 0}], "reasoning_content": "which"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " both functions", "format": "unknown", "index": 0}], "reasoning_content": " both functions"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " means calling", "format": "unknown", "index": 0}], "reasoning_content": " means calling"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ":\n-", "format": "unknown", "index": 0}], "reasoning_content": ":\n-"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " them in", "format": "unknown", "index": 0}], "reasoning_content": " them in"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " add(", "format": "unknown", "index": 0}], "reasoning_content": " add("}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " the", "format": "unknown", "index": 0}], "reasoning_content": " the"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "2,", "format": "unknown", "index": 0}], "reasoning_content": "2,"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " same function", "format": "unknown", "index": 0}], "reasoning_content": " same function"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " 3", "format": "unknown", "index": 0}], "reasoning_content": " 3"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "_calls", "format": "unknown", "index": 0}], "reasoning_content": "_calls"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ")\n-", "format": "unknown", "index": 0}], "reasoning_content": ")\n-"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " block).\n\n", "format": "unknown", "index": 0}], "reasoning_content": " block).\n\n"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " multiply(", "format": "unknown", "index": 0}], "reasoning_content": " multiply("}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "So", "format": "unknown", "index": 0}], "reasoning_content": "So"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "2,", "format": "unknown", "index": 0}], "reasoning_content": "2,"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " I need", "format": "unknown", "index": 0}], "reasoning_content": " I need"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " 3", "format": "unknown", "index": 0}], "reasoning_content": " 3"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " to:\n", "format": "unknown", "index": 0}], "reasoning_content": " to:\n"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ")\n\nSince", "format": "unknown", "index": 0}], "reasoning_content": ")\n\nSince"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "1.", "format": "unknown", "index": 0}], "reasoning_content": "1."}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " these are", "format": "unknown", "index": 0}], "reasoning_content": " these are"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " Call add", "format": "unknown", "index": 0}], "reasoning_content": " Call add"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " independent operations", "format": "unknown", "index": 0}], "reasoning_content": " independent operations"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " with a", "format": "unknown", "index": 0}], "reasoning_content": " with a"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " (", "format": "unknown", "index": 0}], "reasoning_content": " ("}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "=2", "format": "unknown", "index": 0}], "reasoning_content": "=2"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "they", "format": "unknown", "index": 0}], "reasoning_content": "they"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ", b", "format": "unknown", "index": 0}], "reasoning_content": ", b"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " don't depend", "format": "unknown", "index": 0}], "reasoning_content": " don't depend"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "=3", "format": "unknown", "index": 0}], "reasoning_content": "=3"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " on each", "format": "unknown", "index": 0}], "reasoning_content": " on each"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "\n2", "format": "unknown", "index": 0}], "reasoning_content": "\n2"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " other),", "format": "unknown", "index": 0}], "reasoning_content": " other),"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ". Call", "format": "unknown", "index": 0}], "reasoning_content": ". Call"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " I can", "format": "unknown", "index": 0}], "reasoning_content": " I can"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " multiply with", "format": "unknown", "index": 0}], "reasoning_content": " multiply with"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " make both", "format": "unknown", "index": 0}], "reasoning_content": " make both"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " calls in", "format": "unknown", "index": 0}], "reasoning_content": " calls in"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " a=", "format": "unknown", "index": 0}], "reasoning_content": " a="}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " the same", "format": "unknown", "index": 0}], "reasoning_content": " the same"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "2,", "format": "unknown", "index": 0}], "reasoning_content": "2,"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " function_calls", "format": "unknown", "index": 0}], "reasoning_content": " function_calls"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " b=", "format": "unknown", "index": 0}], "reasoning_content": " b="}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " block.", "format": "unknown", "index": 0}], "reasoning_content": " block."}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "3\n\n", "format": "unknown", "index": 0}], "reasoning_content": "3\n\n"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " ", "format": "unknown", "index": 0}], "reasoning_content": " "}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "I'll", "format": "unknown", "index": 0}], "reasoning_content": "I'll"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " I'll", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " make", "format": "unknown", "index": 0}], "reasoning_content": " make"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " add and", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " both calls", "format": "unknown", "index": 0}], "reasoning_content": " both calls"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " multiply ", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " in the", "format": "unknown", "index": 0}], "reasoning_content": " in the"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "2 and", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " same function", "format": "unknown", "index": 0}], "reasoning_content": " same function"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " 3", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "_calls block", "format": "unknown", "index": 0}], "reasoning_content": "_calls block"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " for you", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " since", "format": "unknown", "index": 0}], "reasoning_content": " since"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " at", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " they are", "format": "unknown", "index": 0}], "reasoning_content": " they are"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " the same", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " independent of", "format": "unknown", "index": 0}], "reasoning_content": " independent of"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " time.", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " each other", "format": "unknown", "index": 0}], "reasoning_content": " each other"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " ", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ". ", "format": "unknown", "index": 0}], "reasoning_content": ". "}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": null, "role": "assistant", "tool_calls": [{"index": 0, "id": "functions.add:0", "type": "function", "function": {"name": "add", "arguments": ""}}]}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " I'll add", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": null, "role": "assistant", "tool_calls": [{"index": 0, "function": {"arguments": "{\"a\": 2, \"b\": 3}"}}]}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " and multiply", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " ", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " the", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": null, "role": "assistant", "tool_calls": [{"index": 1, "id": "functions.multiply:1", "type": "function", "function": {"name": "multiply", "arguments": ""}}]}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " numbers ", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": null, "role": "assistant", "tool_calls": [{"index": 1, "function": {"arguments": "{\"a\": 2, \"b\": 3}"}}]}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "2 and", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_content": null}, "finish_reason": "tool_calls", "native_finish_reason": "tool_calls"}]} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " 3", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932884-wGz1eX8aEKiwuYKtVVBo", "object": "chat.completion.chunk", "created": 1773932884, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant"}, "finish_reason": "tool_calls", "native_finish_reason": "tool_calls"}], "usage": {"prompt_tokens": 154, "completion_tokens": 131, "total_tokens": 285, "cost": 0.0003287, "prompt_tokens_details": {"cached_tokens": 128, "cache_write_tokens": 0, "audio_tokens": 0, "video_tokens": 0}, "completion_tokens_details": {"reasoning_tokens": 87, "image_tokens": 0, "audio_tokens": 0}}} + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " for you", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " using both", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " operations", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": ". ", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " ", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + + + + + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": null, "role": "assistant", "tool_calls": [{"index": 0, "id": "functions.add:0", "type": "function", "function": {"name": "add", "arguments": ""}}]}, "finish_reason": null, "native_finish_reason": null}]} + + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": null, "role": "assistant", "tool_calls": [{"index": 0, "function": {"arguments": "{\"a\": 2, \"b\": 3}"}}]}, "finish_reason": null, "native_finish_reason": null}]} + + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " ", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": null, "role": "assistant", "tool_calls": [{"index": 1, "id": "functions.multiply:1", "type": "function", "function": {"name": "multiply", "arguments": ""}}]}, "finish_reason": null, "native_finish_reason": null}]} + + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": null, "role": "assistant", "tool_calls": [{"index": 1, "function": {"arguments": "{\"a\": 2, \"b\": 3}"}}]}, "finish_reason": null, "native_finish_reason": null}]} + + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_content": null}, "finish_reason": "tool_calls", "native_finish_reason": "tool_calls"}]} + + data: {"id": "gen-1787940175-vloQES0Y8dbGoX4T9wNv", "object": "chat.completion.chunk", "created": 1787940175, "model": "moonshotai/kimi-k2.5", "service_tier": null, "choices": [{"index": 0, "delta": {"content": "", "role": "assistant"}, "finish_reason": "tool_calls", "native_finish_reason": "tool_calls"}], "usage": {"prompt_tokens": 154, "completion_tokens": 152, "total_tokens": 306, "cost": 0.0004037, "prompt_tokens_details": {"cached_tokens": 0, "cache_write_tokens": 0, "audio_tokens": 0, "video_tokens": 0}, "completion_tokens_details": {"reasoning_tokens": 96, "image_tokens": 0, "audio_tokens": 0}}} data: [DONE] @@ -155,22 +178,22 @@ interactions: - text/event-stream; charset=utf-8 status: 200 OK code: 200 - duration: 362.715792ms + duration: 434.84025ms - id: 1 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 - content_length: 1620 + content_length: 1745 host: "" - body: '{"messages":[{"content":"You are a helpful assistant. Always use both add and multiply at the same time.","role":"system"},{"content":"Add and multiply the number 2 and 3","role":"user"},{"content":" ","tool_calls":[{"id":"functions.add:0","function":{"arguments":"{\"a\": 2, \"b\": 3}","name":"add"},"type":"function"},{"id":"functions.multiply:1","function":{"arguments":"{\"a\": 2, \"b\": 3}","name":"multiply"},"type":"function"}],"role":"assistant","reasoning_content":"The user wants me to add and multiply the numbers 2 and 3. The instruction says \"Always use both add and multiply at the same time.\"\n\nSo I need to call both functions:\n- add(2, 3)\n- multiply(2, 3)\n\nSince these are independent operations (they don''t depend on each other), I can make both calls in the same function_calls block. "},{"content":"5","tool_call_id":"functions.add:0","role":"tool"},{"content":"6","tool_call_id":"functions.multiply:1","role":"tool"}],"model":"moonshotai/kimi-k2.5","max_tokens":4000,"stream_options":{"include_usage":true},"tool_choice":"auto","tools":[{"function":{"name":"add","strict":false,"description":"Add two numbers","parameters":{"properties":{"a":{"description":"first number","type":"integer"},"b":{"description":"second number","type":"integer"}},"required":["a","b"],"type":"object"}},"type":"function"},{"function":{"name":"multiply","strict":false,"description":"Multiply two numbers","parameters":{"properties":{"a":{"description":"first number","type":"integer"},"b":{"description":"second number","type":"integer"}},"required":["a","b"],"type":"object"}},"type":"function"}],"stream":true}' + body: '{"messages":[{"content":"You are a helpful assistant. Always use both add and multiply at the same time.","role":"system"},{"content":"Add and multiply the number 2 and 3","role":"user"},{"content":" I''ll add and multiply the numbers 2 and 3 for you using both operations. \n ","tool_calls":[{"id":"functions.add:0","function":{"arguments":"{\"a\": 2, \"b\": 3}","name":"add"},"type":"function"},{"id":"functions.multiply:1","function":{"arguments":"{\"a\": 2, \"b\": 3}","name":"multiply"},"type":"function"}],"role":"assistant","reasoning_content":"The user wants me to add and multiply the numbers 2 and 3. According to my instructions, I should always use both add and multiply at the same time (which means calling them in the same function_calls block).\n\nSo I need to:\n1. Call add with a=2, b=3\n2. Call multiply with a=2, b=3\n\nI''ll make both calls in the same function_calls block since they are independent of each other. "},{"content":"5","tool_call_id":"functions.add:0","role":"tool"},{"content":"6","tool_call_id":"functions.multiply:1","role":"tool"}],"model":"moonshotai/kimi-k2.5","max_tokens":4000,"stream_options":{"include_usage":true},"tool_choice":"auto","tools":[{"function":{"name":"add","strict":false,"description":"Add two numbers","parameters":{"properties":{"a":{"description":"first number","type":"integer"},"b":{"description":"second number","type":"integer"}},"required":["a","b"],"type":"object"}},"type":"function"},{"function":{"name":"multiply","strict":false,"description":"Multiply two numbers","parameters":{"properties":{"a":{"description":"first number","type":"integer"},"b":{"description":"second number","type":"integer"}},"required":["a","b"],"type":"object"}},"type":"function"}],"stream":true}' headers: Accept: - application/json Content-Type: - application/json User-Agent: - - Charm-Fantasy/0.15.0 (https://charm.land/fantasy) + - Charm-Fantasy/0.41.3 (https://charm.land/fantasy) url: https://api.avian.io/v1/chat/completions method: POST response: @@ -179,90 +202,86 @@ interactions: proto_minor: 0 content_length: -1 body: |+ - data: {"id": "chatcmpl-3e07ab841333408298e7e6fe", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": null}]} - - - data: {"id": "gen-1773932887-UC9ZiV5rru8IUs3iJx0w", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "Great", "format": "unknown", "index": 0}], "reasoning_content": "Great"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "chatcmpl-769fd23b01884e5288db3da0", "object": "chat.completion.chunk", "created": 1787940182, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": null}]} - data: {"id": "gen-1773932887-UC9ZiV5rru8IUs3iJx0w", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "! I", "format": "unknown", "index": 0}], "reasoning_content": "! I"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932887-UC9ZiV5rru8IUs3iJx0w", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " got the", "format": "unknown", "index": 0}], "reasoning_content": " got the"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940182-CwFVF1DOSxpaCzVOagww", "object": "chat.completion.chunk", "created": 1787940182, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "The", "format": "unknown", "index": 0}], "reasoning_content": "The"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932887-UC9ZiV5rru8IUs3iJx0w", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " results:\n", "format": "unknown", "index": 0}], "reasoning_content": " results:\n"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940182-CwFVF1DOSxpaCzVOagww", "object": "chat.completion.chunk", "created": 1787940182, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " results are", "format": "unknown", "index": 0}], "reasoning_content": " results are"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932887-UC9ZiV5rru8IUs3iJx0w", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "- add", "format": "unknown", "index": 0}], "reasoning_content": "- add"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940182-CwFVF1DOSxpaCzVOagww", "object": "chat.completion.chunk", "created": 1787940182, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ":\n-", "format": "unknown", "index": 0}], "reasoning_content": ":\n-"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932887-UC9ZiV5rru8IUs3iJx0w", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "(2", "format": "unknown", "index": 0}], "reasoning_content": "(2"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940182-CwFVF1DOSxpaCzVOagww", "object": "chat.completion.chunk", "created": 1787940182, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " add(", "format": "unknown", "index": 0}], "reasoning_content": " add("}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932887-UC9ZiV5rru8IUs3iJx0w", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ", ", "format": "unknown", "index": 0}], "reasoning_content": ", "}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940182-CwFVF1DOSxpaCzVOagww", "object": "chat.completion.chunk", "created": 1787940182, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "2,", "format": "unknown", "index": 0}], "reasoning_content": "2,"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932887-UC9ZiV5rru8IUs3iJx0w", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "3)", "format": "unknown", "index": 0}], "reasoning_content": "3)"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940182-CwFVF1DOSxpaCzVOagww", "object": "chat.completion.chunk", "created": 1787940182, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " 3", "format": "unknown", "index": 0}], "reasoning_content": " 3"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932887-UC9ZiV5rru8IUs3iJx0w", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " = ", "format": "unknown", "index": 0}], "reasoning_content": " = "}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940182-CwFVF1DOSxpaCzVOagww", "object": "chat.completion.chunk", "created": 1787940182, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ") =", "format": "unknown", "index": 0}], "reasoning_content": ") ="}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932887-UC9ZiV5rru8IUs3iJx0w", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "5\n", "format": "unknown", "index": 0}], "reasoning_content": "5\n"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940182-CwFVF1DOSxpaCzVOagww", "object": "chat.completion.chunk", "created": 1787940182, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " 5", "format": "unknown", "index": 0}], "reasoning_content": " 5"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932887-UC9ZiV5rru8IUs3iJx0w", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "- multiply", "format": "unknown", "index": 0}], "reasoning_content": "- multiply"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940182-CwFVF1DOSxpaCzVOagww", "object": "chat.completion.chunk", "created": 1787940182, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "\n-", "format": "unknown", "index": 0}], "reasoning_content": "\n-"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932887-UC9ZiV5rru8IUs3iJx0w", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "(2", "format": "unknown", "index": 0}], "reasoning_content": "(2"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940182-CwFVF1DOSxpaCzVOagww", "object": "chat.completion.chunk", "created": 1787940182, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " multiply(", "format": "unknown", "index": 0}], "reasoning_content": " multiply("}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932887-UC9ZiV5rru8IUs3iJx0w", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ", ", "format": "unknown", "index": 0}], "reasoning_content": ", "}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940182-CwFVF1DOSxpaCzVOagww", "object": "chat.completion.chunk", "created": 1787940182, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "2,", "format": "unknown", "index": 0}], "reasoning_content": "2,"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932887-UC9ZiV5rru8IUs3iJx0w", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "3)", "format": "unknown", "index": 0}], "reasoning_content": "3)"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940182-CwFVF1DOSxpaCzVOagww", "object": "chat.completion.chunk", "created": 1787940182, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " 3", "format": "unknown", "index": 0}], "reasoning_content": " 3"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932887-UC9ZiV5rru8IUs3iJx0w", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " = ", "format": "unknown", "index": 0}], "reasoning_content": " = "}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940182-CwFVF1DOSxpaCzVOagww", "object": "chat.completion.chunk", "created": 1787940182, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ") =", "format": "unknown", "index": 0}], "reasoning_content": ") ="}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932887-UC9ZiV5rru8IUs3iJx0w", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "6\n\n", "format": "unknown", "index": 0}], "reasoning_content": "6\n\n"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940182-CwFVF1DOSxpaCzVOagww", "object": "chat.completion.chunk", "created": 1787940182, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " 6", "format": "unknown", "index": 0}], "reasoning_content": " 6"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932887-UC9ZiV5rru8IUs3iJx0w", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "Now I'll", "format": "unknown", "index": 0}], "reasoning_content": "Now I'll"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940182-CwFVF1DOSxpaCzVOagww", "object": "chat.completion.chunk", "created": 1787940182, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "\n\nI", "format": "unknown", "index": 0}], "reasoning_content": "\n\nI"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932887-UC9ZiV5rru8IUs3iJx0w", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " present", "format": "unknown", "index": 0}], "reasoning_content": " present"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940182-CwFVF1DOSxpaCzVOagww", "object": "chat.completion.chunk", "created": 1787940182, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " should present", "format": "unknown", "index": 0}], "reasoning_content": " should present"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932887-UC9ZiV5rru8IUs3iJx0w", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " these", "format": "unknown", "index": 0}], "reasoning_content": " these"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940182-CwFVF1DOSxpaCzVOagww", "object": "chat.completion.chunk", "created": 1787940182, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " these results", "format": "unknown", "index": 0}], "reasoning_content": " these results"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932887-UC9ZiV5rru8IUs3iJx0w", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " results to", "format": "unknown", "index": 0}], "reasoning_content": " results to"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940182-CwFVF1DOSxpaCzVOagww", "object": "chat.completion.chunk", "created": 1787940182, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " to", "format": "unknown", "index": 0}], "reasoning_content": " to"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932887-UC9ZiV5rru8IUs3iJx0w", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " the user", "format": "unknown", "index": 0}], "reasoning_content": " the user"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940182-CwFVF1DOSxpaCzVOagww", "object": "chat.completion.chunk", "created": 1787940182, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " the user", "format": "unknown", "index": 0}], "reasoning_content": " the user"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932887-UC9ZiV5rru8IUs3iJx0w", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ". ", "format": "unknown", "index": 0}], "reasoning_content": ". "}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940182-CwFVF1DOSxpaCzVOagww", "object": "chat.completion.chunk", "created": 1787940182, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " clearly.", "format": "unknown", "index": 0}], "reasoning_content": " clearly."}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932887-UC9ZiV5rru8IUs3iJx0w", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " When", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940182-CwFVF1DOSxpaCzVOagww", "object": "chat.completion.chunk", "created": 1787940182, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " ", "format": "unknown", "index": 0}], "reasoning_content": " "}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932887-UC9ZiV5rru8IUs3iJx0w", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " you add", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940182-CwFVF1DOSxpaCzVOagww", "object": "chat.completion.chunk", "created": 1787940182, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " Here", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932887-UC9ZiV5rru8IUs3iJx0w", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " 2", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940182-CwFVF1DOSxpaCzVOagww", "object": "chat.completion.chunk", "created": 1787940182, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " are the", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932887-UC9ZiV5rru8IUs3iJx0w", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " and ", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940182-CwFVF1DOSxpaCzVOagww", "object": "chat.completion.chunk", "created": 1787940182, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " results:\n\n", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932887-UC9ZiV5rru8IUs3iJx0w", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "3,", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940182-CwFVF1DOSxpaCzVOagww", "object": "chat.completion.chunk", "created": 1787940182, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "**", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932887-UC9ZiV5rru8IUs3iJx0w", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " the", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940182-CwFVF1DOSxpaCzVOagww", "object": "chat.completion.chunk", "created": 1787940182, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "Addition:**", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932887-UC9ZiV5rru8IUs3iJx0w", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " result is", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940182-CwFVF1DOSxpaCzVOagww", "object": "chat.completion.chunk", "created": 1787940182, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " 2", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932887-UC9ZiV5rru8IUs3iJx0w", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " **5", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940182-CwFVF1DOSxpaCzVOagww", "object": "chat.completion.chunk", "created": 1787940182, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " + ", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932887-UC9ZiV5rru8IUs3iJx0w", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "**.\n", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940182-CwFVF1DOSxpaCzVOagww", "object": "chat.completion.chunk", "created": 1787940182, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "3 =", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932887-UC9ZiV5rru8IUs3iJx0w", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "When you", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940182-CwFVF1DOSxpaCzVOagww", "object": "chat.completion.chunk", "created": 1787940182, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " **5", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932887-UC9ZiV5rru8IUs3iJx0w", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " multiply ", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940182-CwFVF1DOSxpaCzVOagww", "object": "chat.completion.chunk", "created": 1787940182, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "**\n\n**", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932887-UC9ZiV5rru8IUs3iJx0w", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "2 and", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940182-CwFVF1DOSxpaCzVOagww", "object": "chat.completion.chunk", "created": 1787940182, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "Multiplication", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932887-UC9ZiV5rru8IUs3iJx0w", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " 3", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940182-CwFVF1DOSxpaCzVOagww", "object": "chat.completion.chunk", "created": 1787940182, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": ":** ", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932887-UC9ZiV5rru8IUs3iJx0w", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": ", the", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940182-CwFVF1DOSxpaCzVOagww", "object": "chat.completion.chunk", "created": 1787940182, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "2 ×", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932887-UC9ZiV5rru8IUs3iJx0w", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " result is", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940182-CwFVF1DOSxpaCzVOagww", "object": "chat.completion.chunk", "created": 1787940182, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " 3", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932887-UC9ZiV5rru8IUs3iJx0w", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " **6", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940182-CwFVF1DOSxpaCzVOagww", "object": "chat.completion.chunk", "created": 1787940182, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " = **", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932887-UC9ZiV5rru8IUs3iJx0w", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "**.", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940182-CwFVF1DOSxpaCzVOagww", "object": "chat.completion.chunk", "created": 1787940182, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "6**", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932887-UC9ZiV5rru8IUs3iJx0w", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_content": null}, "finish_reason": "stop", "native_finish_reason": "stop"}]} + data: {"id": "gen-1787940182-CwFVF1DOSxpaCzVOagww", "object": "chat.completion.chunk", "created": 1787940182, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_content": null}, "finish_reason": "stop", "native_finish_reason": "stop"}]} - data: {"id": "gen-1773932887-UC9ZiV5rru8IUs3iJx0w", "object": "chat.completion.chunk", "created": 1773932887, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant"}, "finish_reason": "stop", "native_finish_reason": "stop"}], "usage": {"prompt_tokens": 308, "completion_tokens": 71, "total_tokens": 379, "cost": 0.0002444, "prompt_tokens_details": {"cached_tokens": 224, "cache_write_tokens": 0, "audio_tokens": 0, "video_tokens": 0}, "completion_tokens_details": {"reasoning_tokens": 30, "image_tokens": 0, "audio_tokens": 0}}} + data: {"id": "gen-1787940182-CwFVF1DOSxpaCzVOagww", "object": "chat.completion.chunk", "created": 1787940182, "model": "moonshotai/kimi-k2.5", "service_tier": null, "choices": [{"index": 0, "delta": {"content": "", "role": "assistant"}, "finish_reason": "stop", "native_finish_reason": "stop"}], "usage": {"prompt_tokens": 340, "completion_tokens": 69, "total_tokens": 409, "cost": 0.0002544, "prompt_tokens_details": {"cached_tokens": 224, "cache_write_tokens": 0, "audio_tokens": 0, "video_tokens": 0}, "completion_tokens_details": {"reasoning_tokens": 28, "image_tokens": 0, "audio_tokens": 0}}} data: [DONE] @@ -271,4 +290,4 @@ interactions: - text/event-stream; charset=utf-8 status: 200 OK code: 200 - duration: 346.239959ms + duration: 408.546208ms diff --git a/providertests/testdata/TestOpenAICompatibleCommon/avian-kimi/simple_streaming.yaml b/providertests/testdata/TestOpenAICompatibleCommon/avian-kimi/simple_streaming.yaml index 97ae95b4c..7ecdcc796 100644 --- a/providertests/testdata/TestOpenAICompatibleCommon/avian-kimi/simple_streaming.yaml +++ b/providertests/testdata/TestOpenAICompatibleCommon/avian-kimi/simple_streaming.yaml @@ -15,7 +15,7 @@ interactions: Content-Type: - application/json User-Agent: - - Charm-Fantasy/0.15.0 (https://charm.land/fantasy) + - Charm-Fantasy/0.41.3 (https://charm.land/fantasy) url: https://api.avian.io/v1/chat/completions method: POST response: @@ -24,284 +24,295 @@ interactions: proto_minor: 0 content_length: -1 body: |+ - data: {"id": "chatcmpl-03d7a8c9ff7843d98715145a", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": null}]} + data: {"id": "chatcmpl-d75fe4426c9246c6bc458b2b", "object": "chat.completion.chunk", "created": 1787940148, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "The", "format": "unknown", "index": 0}], "reasoning_content": "The"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "The", "format": "unknown", "index": 0}], "reasoning_content": "The"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " user wants", "format": "unknown", "index": 0}], "reasoning_content": " user wants"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " user wants", "format": "unknown", "index": 0}], "reasoning_content": " user wants"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " me to", "format": "unknown", "index": 0}], "reasoning_content": " me to"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " me to", "format": "unknown", "index": 0}], "reasoning_content": " me to"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " say \"", "format": "unknown", "index": 0}], "reasoning_content": " say \""}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " say \"", "format": "unknown", "index": 0}], "reasoning_content": " say \""}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "hi\"", "format": "unknown", "index": 0}], "reasoning_content": "hi\""}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "hi\"", "format": "unknown", "index": 0}], "reasoning_content": "hi\""}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " in Portuguese", "format": "unknown", "index": 0}], "reasoning_content": " in Portuguese"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " in Portuguese", "format": "unknown", "index": 0}], "reasoning_content": " in Portuguese"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ". \n\n", "format": "unknown", "index": 0}], "reasoning_content": ". \n\n"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ". \n\n", "format": "unknown", "index": 0}], "reasoning_content": ". \n\n"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "In", "format": "unknown", "index": 0}], "reasoning_content": "In"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "The", "format": "unknown", "index": 0}], "reasoning_content": "The"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " Portuguese,", "format": "unknown", "index": 0}], "reasoning_content": " Portuguese,"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " most common", "format": "unknown", "index": 0}], "reasoning_content": " most common"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " the common", "format": "unknown", "index": 0}], "reasoning_content": " the common"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " ways to", "format": "unknown", "index": 0}], "reasoning_content": " ways to"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " ways to", "format": "unknown", "index": 0}], "reasoning_content": " ways to"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " say \"", "format": "unknown", "index": 0}], "reasoning_content": " say \""}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " say \"", "format": "unknown", "index": 0}], "reasoning_content": " say \""}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "hi\"", "format": "unknown", "index": 0}], "reasoning_content": "hi\""}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "hi\"", "format": "unknown", "index": 0}], "reasoning_content": "hi\""}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " are:\n", "format": "unknown", "index": 0}], "reasoning_content": " are:\n"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " in Portuguese", "format": "unknown", "index": 0}], "reasoning_content": " in Portuguese"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "- \"", "format": "unknown", "index": 0}], "reasoning_content": "- \""}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " are:\n", "format": "unknown", "index": 0}], "reasoning_content": " are:\n"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "Oi", "format": "unknown", "index": 0}], "reasoning_content": "Oi"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "- \"", "format": "unknown", "index": 0}], "reasoning_content": "- \""}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "\" -", "format": "unknown", "index": 0}], "reasoning_content": "\" -"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "Olá", "format": "unknown", "index": 0}], "reasoning_content": "Olá"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " informal", "format": "unknown", "index": 0}], "reasoning_content": " informal"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "\" -", "format": "unknown", "index": 0}], "reasoning_content": "\" -"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ", most", "format": "unknown", "index": 0}], "reasoning_content": ", most"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " formal and", "format": "unknown", "index": 0}], "reasoning_content": " formal and"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " common\n", "format": "unknown", "index": 0}], "reasoning_content": " common\n"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " versatile", "format": "unknown", "index": 0}], "reasoning_content": " versatile"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "- \"", "format": "unknown", "index": 0}], "reasoning_content": "- \""}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ", works", "format": "unknown", "index": 0}], "reasoning_content": ", works"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "Olá", "format": "unknown", "index": 0}], "reasoning_content": "Olá"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " in", "format": "unknown", "index": 0}], "reasoning_content": " in"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "\" -", "format": "unknown", "index": 0}], "reasoning_content": "\" -"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " most situations", "format": "unknown", "index": 0}], "reasoning_content": " most situations"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " slightly", "format": "unknown", "index": 0}], "reasoning_content": " slightly"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "\n-", "format": "unknown", "index": 0}], "reasoning_content": "\n-"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " more formal", "format": "unknown", "index": 0}], "reasoning_content": " more formal"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " \"Oi", "format": "unknown", "index": 0}], "reasoning_content": " \"Oi"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ", means", "format": "unknown", "index": 0}], "reasoning_content": ", means"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "\" -", "format": "unknown", "index": 0}], "reasoning_content": "\" -"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " \"hello", "format": "unknown", "index": 0}], "reasoning_content": " \"hello"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " informal,", "format": "unknown", "index": 0}], "reasoning_content": " informal,"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "\"\n-", "format": "unknown", "index": 0}], "reasoning_content": "\"\n-"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " very", "format": "unknown", "index": 0}], "reasoning_content": " very"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " \"E", "format": "unknown", "index": 0}], "reasoning_content": " \"E"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " common in", "format": "unknown", "index": 0}], "reasoning_content": " common in"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " a", "format": "unknown", "index": 0}], "reasoning_content": " a"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " Brazil\n", "format": "unknown", "index": 0}], "reasoning_content": " Brazil\n"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "í?\"", "format": "unknown", "index": 0}], "reasoning_content": "í?\""}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "- \"", "format": "unknown", "index": 0}], "reasoning_content": "- \""}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " -", "format": "unknown", "index": 0}], "reasoning_content": " -"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "Alô", "format": "unknown", "index": 0}], "reasoning_content": "Alô"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " very informal", "format": "unknown", "index": 0}], "reasoning_content": " very informal"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "\" -", "format": "unknown", "index": 0}], "reasoning_content": "\" -"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ", like", "format": "unknown", "index": 0}], "reasoning_content": ", like"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " used when", "format": "unknown", "index": 0}], "reasoning_content": " used when"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " \"what", "format": "unknown", "index": 0}], "reasoning_content": " \"what"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " answering the", "format": "unknown", "index": 0}], "reasoning_content": " answering the"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "'s up", "format": "unknown", "index": 0}], "reasoning_content": "'s up"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " phone\n", "format": "unknown", "index": 0}], "reasoning_content": " phone\n"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "?\"\n", "format": "unknown", "index": 0}], "reasoning_content": "?\"\n"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "- \"", "format": "unknown", "index": 0}], "reasoning_content": "- \""}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "- \"", "format": "unknown", "index": 0}], "reasoning_content": "- \""}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "E a", "format": "unknown", "index": 0}], "reasoning_content": "E a"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "T", "format": "unknown", "index": 0}], "reasoning_content": "T"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "í?\"", "format": "unknown", "index": 0}], "reasoning_content": "í?\""}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "udo", "format": "unknown", "index": 0}], "reasoning_content": "udo"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " - very", "format": "unknown", "index": 0}], "reasoning_content": " - very"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " bem", "format": "unknown", "index": 0}], "reasoning_content": " bem"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " informal", "format": "unknown", "index": 0}], "reasoning_content": " informal"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "?\" -", "format": "unknown", "index": 0}], "reasoning_content": "?\" -"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ", like", "format": "unknown", "index": 0}], "reasoning_content": ", like"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " \"how", "format": "unknown", "index": 0}], "reasoning_content": " \"how"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " \"what", "format": "unknown", "index": 0}], "reasoning_content": " \"what"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " are you", "format": "unknown", "index": 0}], "reasoning_content": " are you"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "'s up", "format": "unknown", "index": 0}], "reasoning_content": "'s up"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "?", "format": "unknown", "index": 0}], "reasoning_content": "?"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "\"\n\nSince", "format": "unknown", "index": 0}], "reasoning_content": "\"\n\nSince"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "\"\n\nThe", "format": "unknown", "index": 0}], "reasoning_content": "\"\n\nThe"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " the user", "format": "unknown", "index": 0}], "reasoning_content": " the user"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " most straightforward", "format": "unknown", "index": 0}], "reasoning_content": " most straightforward"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " just asked", "format": "unknown", "index": 0}], "reasoning_content": " just asked"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " and standard", "format": "unknown", "index": 0}], "reasoning_content": " and standard"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " for \"", "format": "unknown", "index": 0}], "reasoning_content": " for \""}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " translation of", "format": "unknown", "index": 0}], "reasoning_content": " translation of"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "hi\",", "format": "unknown", "index": 0}], "reasoning_content": "hi\","}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " \"hi", "format": "unknown", "index": 0}], "reasoning_content": " \"hi"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " I", "format": "unknown", "index": 0}], "reasoning_content": " I"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "\" is", "format": "unknown", "index": 0}], "reasoning_content": "\" is"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " should provide", "format": "unknown", "index": 0}], "reasoning_content": " should provide"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " \"Oi", "format": "unknown", "index": 0}], "reasoning_content": " \"Oi"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " the most", "format": "unknown", "index": 0}], "reasoning_content": " the most"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "\" or", "format": "unknown", "index": 0}], "reasoning_content": "\" or"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " common and", "format": "unknown", "index": 0}], "reasoning_content": " common and"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " \"Ol", "format": "unknown", "index": 0}], "reasoning_content": " \"Ol"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " standard options", "format": "unknown", "index": 0}], "reasoning_content": " standard options"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "á\".", "format": "unknown", "index": 0}], "reasoning_content": "á\"."}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " I should", "format": "unknown", "index": 0}], "reasoning_content": " I should"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " provide the", "format": "unknown", "index": 0}], "reasoning_content": " provide the"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ". \"", "format": "unknown", "index": 0}], "reasoning_content": ". \""}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " most common", "format": "unknown", "index": 0}], "reasoning_content": " most common"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "Olá", "format": "unknown", "index": 0}], "reasoning_content": "Olá"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " one,", "format": "unknown", "index": 0}], "reasoning_content": " one,"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "\" and", "format": "unknown", "index": 0}], "reasoning_content": "\" and"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " which", "format": "unknown", "index": 0}], "reasoning_content": " which"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " \"Oi", "format": "unknown", "index": 0}], "reasoning_content": " \"Oi"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " is \"", "format": "unknown", "index": 0}], "reasoning_content": " is \""}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "\" are", "format": "unknown", "index": 0}], "reasoning_content": "\" are"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "Oi\",", "format": "unknown", "index": 0}], "reasoning_content": "Oi\","}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " the most", "format": "unknown", "index": 0}], "reasoning_content": " the most"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " but maybe", "format": "unknown", "index": 0}], "reasoning_content": " but maybe"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " appropriate.", "format": "unknown", "index": 0}], "reasoning_content": " appropriate."}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " also mention", "format": "unknown", "index": 0}], "reasoning_content": " also mention"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " I", "format": "unknown", "index": 0}], "reasoning_content": " I"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " \"Ol", "format": "unknown", "index": 0}], "reasoning_content": " \"Ol"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " should probably", "format": "unknown", "index": 0}], "reasoning_content": " should probably"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "á\"", "format": "unknown", "index": 0}], "reasoning_content": "á\""}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " mention both", "format": "unknown", "index": 0}], "reasoning_content": " mention both"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " as it's", "format": "unknown", "index": 0}], "reasoning_content": " as it's"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " since", "format": "unknown", "index": 0}], "reasoning_content": " since"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " also very", "format": "unknown", "index": 0}], "reasoning_content": " also very"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " \"Oi", "format": "unknown", "index": 0}], "reasoning_content": " \"Oi"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " common.\n\n", "format": "unknown", "index": 0}], "reasoning_content": " common.\n\n"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "\" is", "format": "unknown", "index": 0}], "reasoning_content": "\" is"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "I'll", "format": "unknown", "index": 0}], "reasoning_content": "I'll"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " very common", "format": "unknown", "index": 0}], "reasoning_content": " very common"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " provide \"", "format": "unknown", "index": 0}], "reasoning_content": " provide \""}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " in Brazil", "format": "unknown", "index": 0}], "reasoning_content": " in Brazil"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "Oi!\"", "format": "unknown", "index": 0}], "reasoning_content": "Oi!\""}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " while", "format": "unknown", "index": 0}], "reasoning_content": " while"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " as the", "format": "unknown", "index": 0}], "reasoning_content": " as the"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " \"Ol", "format": "unknown", "index": 0}], "reasoning_content": " \"Ol"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " primary answer", "format": "unknown", "index": 0}], "reasoning_content": " primary answer"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "á\"", "format": "unknown", "index": 0}], "reasoning_content": "á\""}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ", and", "format": "unknown", "index": 0}], "reasoning_content": ", and"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " is more", "format": "unknown", "index": 0}], "reasoning_content": " is more"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " perhaps", "format": "unknown", "index": 0}], "reasoning_content": " perhaps"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " universal and", "format": "unknown", "index": 0}], "reasoning_content": " universal and"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " mention \"", "format": "unknown", "index": 0}], "reasoning_content": " mention \""}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " formal", "format": "unknown", "index": 0}], "reasoning_content": " formal"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "Olá", "format": "unknown", "index": 0}], "reasoning_content": "Olá"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ".\n\nI'll", "format": "unknown", "index": 0}], "reasoning_content": ".\n\nI'll"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "\" as", "format": "unknown", "index": 0}], "reasoning_content": "\" as"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " provide \"", "format": "unknown", "index": 0}], "reasoning_content": " provide \""}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " an alternative", "format": "unknown", "index": 0}], "reasoning_content": " an alternative"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "Olá", "format": "unknown", "index": 0}], "reasoning_content": "Olá"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " since", "format": "unknown", "index": 0}], "reasoning_content": " since"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "\" as", "format": "unknown", "index": 0}], "reasoning_content": "\" as"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " it's also", "format": "unknown", "index": 0}], "reasoning_content": " it's also"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " the main", "format": "unknown", "index": 0}], "reasoning_content": " the main"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " very standard", "format": "unknown", "index": 0}], "reasoning_content": " very standard"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " answer since", "format": "unknown", "index": 0}], "reasoning_content": " answer since"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ".\n\nActually", "format": "unknown", "index": 0}], "reasoning_content": ".\n\nActually"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " it's the", "format": "unknown", "index": 0}], "reasoning_content": " it's the"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ", looking", "format": "unknown", "index": 0}], "reasoning_content": ", looking"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " most", "format": "unknown", "index": 0}], "reasoning_content": " most"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " at the", "format": "unknown", "index": 0}], "reasoning_content": " at the"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " standard,", "format": "unknown", "index": 0}], "reasoning_content": " standard,"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " simplicity", "format": "unknown", "index": 0}], "reasoning_content": " simplicity"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " and mention", "format": "unknown", "index": 0}], "reasoning_content": " and mention"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " of the", "format": "unknown", "index": 0}], "reasoning_content": " of the"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " \"Oi", "format": "unknown", "index": 0}], "reasoning_content": " \"Oi"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " request,", "format": "unknown", "index": 0}], "reasoning_content": " request,"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "\" as", "format": "unknown", "index": 0}], "reasoning_content": "\" as"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " I should", "format": "unknown", "index": 0}], "reasoning_content": " I should"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " a", "format": "unknown", "index": 0}], "reasoning_content": " a"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " probably", "format": "unknown", "index": 0}], "reasoning_content": " probably"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " common informal", "format": "unknown", "index": 0}], "reasoning_content": " common informal"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " just give", "format": "unknown", "index": 0}], "reasoning_content": " just give"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " alternative,", "format": "unknown", "index": 0}], "reasoning_content": " alternative,"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " a", "format": "unknown", "index": 0}], "reasoning_content": " a"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " especially in", "format": "unknown", "index": 0}], "reasoning_content": " especially in"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " direct", "format": "unknown", "index": 0}], "reasoning_content": " direct"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " Brazil", "format": "unknown", "index": 0}], "reasoning_content": " Brazil"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " answer with", "format": "unknown", "index": 0}], "reasoning_content": " answer with"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ".\n\n", "format": "unknown", "index": 0}], "reasoning_content": ".\n\n"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " perhaps", "format": "unknown", "index": 0}], "reasoning_content": " perhaps"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "I", "format": "unknown", "index": 0}], "reasoning_content": "I"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " a brief", "format": "unknown", "index": 0}], "reasoning_content": " a brief"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " should also", "format": "unknown", "index": 0}], "reasoning_content": " should also"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " note", "format": "unknown", "index": 0}], "reasoning_content": " note"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " note", "format": "unknown", "index": 0}], "reasoning_content": " note"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " about form", "format": "unknown", "index": 0}], "reasoning_content": " about form"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " that Portuguese", "format": "unknown", "index": 0}], "reasoning_content": " that Portuguese"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "ality.\n\n", "format": "unknown", "index": 0}], "reasoning_content": "ality.\n\n"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " is", "format": "unknown", "index": 0}], "reasoning_content": " is"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "Best", "format": "unknown", "index": 0}], "reasoning_content": "Best"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " spoken in", "format": "unknown", "index": 0}], "reasoning_content": " spoken in"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " answer:", "format": "unknown", "index": 0}], "reasoning_content": " answer:"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " different countries", "format": "unknown", "index": 0}], "reasoning_content": " different countries"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " \"Oi", "format": "unknown", "index": 0}], "reasoning_content": " \"Oi"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " (Port", "format": "unknown", "index": 0}], "reasoning_content": " (Port"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "!\"", "format": "unknown", "index": 0}], "reasoning_content": "!\""}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "ugal,", "format": "unknown", "index": 0}], "reasoning_content": "ugal,"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " or \"", "format": "unknown", "index": 0}], "reasoning_content": " or \""}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " Brazil,", "format": "unknown", "index": 0}], "reasoning_content": " Brazil,"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "Olá", "format": "unknown", "index": 0}], "reasoning_content": "Olá"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " Angola", "format": "unknown", "index": 0}], "reasoning_content": " Angola"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "!\"\n\n", "format": "unknown", "index": 0}], "reasoning_content": "!\"\n\n"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ", etc", "format": "unknown", "index": 0}], "reasoning_content": ", etc"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "I'll go", "format": "unknown", "index": 0}], "reasoning_content": "I'll go"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ".) and", "format": "unknown", "index": 0}], "reasoning_content": ".) and"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " with \"", "format": "unknown", "index": 0}], "reasoning_content": " with \""}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " there might", "format": "unknown", "index": 0}], "reasoning_content": " there might"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "Oi!\"", "format": "unknown", "index": 0}], "reasoning_content": "Oi!\""}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " be slight", "format": "unknown", "index": 0}], "reasoning_content": " be slight"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " as the", "format": "unknown", "index": 0}], "reasoning_content": " as the"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " variations,", "format": "unknown", "index": 0}], "reasoning_content": " variations,"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " main answer", "format": "unknown", "index": 0}], "reasoning_content": " main answer"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " but \"", "format": "unknown", "index": 0}], "reasoning_content": " but \""}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " since it's", "format": "unknown", "index": 0}], "reasoning_content": " since it's"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "Olá", "format": "unknown", "index": 0}], "reasoning_content": "Olá"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " the most", "format": "unknown", "index": 0}], "reasoning_content": " the most"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "\" works", "format": "unknown", "index": 0}], "reasoning_content": "\" works"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " direct equivalent", "format": "unknown", "index": 0}], "reasoning_content": " direct equivalent"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " everywhere.\n\n", "format": "unknown", "index": 0}], "reasoning_content": " everywhere.\n\n"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " to", "format": "unknown", "index": 0}], "reasoning_content": " to"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "Let me", "format": "unknown", "index": 0}], "reasoning_content": "Let me"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " the", "format": "unknown", "index": 0}], "reasoning_content": " the"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " casual English", "format": "unknown", "index": 0}], "reasoning_content": " casual English"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " provide", "format": "unknown", "index": 0}], "reasoning_content": " provide"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " \"hi", "format": "unknown", "index": 0}], "reasoning_content": " \"hi"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " a helpful", "format": "unknown", "index": 0}], "reasoning_content": " a helpful"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "\",", "format": "unknown", "index": 0}], "reasoning_content": "\","}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ", complete", "format": "unknown", "index": 0}], "reasoning_content": ", complete"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " and mention", "format": "unknown", "index": 0}], "reasoning_content": " and mention"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " answer", "format": "unknown", "index": 0}], "reasoning_content": " answer"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " \"Ol", "format": "unknown", "index": 0}], "reasoning_content": " \"Ol"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ". ", "format": "unknown", "index": 0}], "reasoning_content": ". "}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "á\"", "format": "unknown", "index": 0}], "reasoning_content": "á\""}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " Olá", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " as a", "format": "unknown", "index": 0}], "reasoning_content": " as a"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "! \n\n", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "(", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " slightly more", "format": "unknown", "index": 0}], "reasoning_content": " slightly more"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "You", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " formal alternative", "format": "unknown", "index": 0}], "reasoning_content": " formal alternative"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " can also", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ". ", "format": "unknown", "index": 0}], "reasoning_content": ". "}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " say **", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " Oi", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "\"Oi", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "! \n\n", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "\"**", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "(Or", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " — it's", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " \"", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " more", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "Olá", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " informal and", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "!\" which", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " very common", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " is slightly", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " in Brazil", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " more formal", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": ", while", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": ")", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " \"", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_content": null}, "finish_reason": "stop", "native_finish_reason": "stop"}]} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "Olá", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932873-cZ28aIfjOk9BQ9QjyZz3", "object": "chat.completion.chunk", "created": 1773932873, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant"}, "finish_reason": "stop", "native_finish_reason": "stop"}], "usage": {"prompt_tokens": 21, "completion_tokens": 242, "total_tokens": 263, "cost": 0.00054005, "prompt_tokens_details": {"cached_tokens": 8, "cache_write_tokens": 0, "audio_tokens": 0, "video_tokens": 0}, "completion_tokens_details": {"reasoning_tokens": 238, "image_tokens": 0, "audio_tokens": 0}}} + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "\" works", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " well", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " in both", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " Brazil", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " and Portugal", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": ".)", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_content": null}, "finish_reason": "stop", "native_finish_reason": "stop"}]} + + data: {"id": "gen-1787940147-bcM7mZyGId4nh5WskKi5", "object": "chat.completion.chunk", "created": 1787940147, "model": "moonshotai/kimi-k2.5", "service_tier": null, "choices": [{"index": 0, "delta": {"content": "", "role": "assistant"}, "finish_reason": "stop", "native_finish_reason": "stop"}], "usage": {"prompt_tokens": 21, "completion_tokens": 252, "total_tokens": 273, "cost": 0.00056385, "prompt_tokens_details": {"cached_tokens": 0, "cache_write_tokens": 0, "audio_tokens": 0, "video_tokens": 0}, "completion_tokens_details": {"reasoning_tokens": 225, "image_tokens": 0, "audio_tokens": 0}}} data: [DONE] @@ -310,4 +321,4 @@ interactions: - text/event-stream; charset=utf-8 status: 200 OK code: 200 - duration: 1.06203225s + duration: 882.788875ms diff --git a/providertests/testdata/TestOpenAICompatibleCommon/avian-kimi/tool_streaming.yaml b/providertests/testdata/TestOpenAICompatibleCommon/avian-kimi/tool_streaming.yaml index a37ecf5df..03d066d9e 100644 --- a/providertests/testdata/TestOpenAICompatibleCommon/avian-kimi/tool_streaming.yaml +++ b/providertests/testdata/TestOpenAICompatibleCommon/avian-kimi/tool_streaming.yaml @@ -15,7 +15,7 @@ interactions: Content-Type: - application/json User-Agent: - - Charm-Fantasy/0.15.0 (https://charm.land/fantasy) + - Charm-Fantasy/0.41.3 (https://charm.land/fantasy) url: https://api.avian.io/v1/chat/completions method: POST response: @@ -24,105 +24,105 @@ interactions: proto_minor: 0 content_length: -1 body: |+ - data: {"id": "chatcmpl-9091e2c02aec4d94a5ad7fe3", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": null}]} + data: {"id": "chatcmpl-7de6723624974838822ecae0", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "The", "format": "unknown", "index": 0}], "reasoning_content": "The"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " user is", "format": "unknown", "index": 0}], "reasoning_content": " user is"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "The", "format": "unknown", "index": 0}], "reasoning_content": "The"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " asking for", "format": "unknown", "index": 0}], "reasoning_content": " asking for"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " user is", "format": "unknown", "index": 0}], "reasoning_content": " user is"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " the weather", "format": "unknown", "index": 0}], "reasoning_content": " the weather"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " asking about", "format": "unknown", "index": 0}], "reasoning_content": " asking about"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " in", "format": "unknown", "index": 0}], "reasoning_content": " in"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " the weather", "format": "unknown", "index": 0}], "reasoning_content": " the weather"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " Florence,", "format": "unknown", "index": 0}], "reasoning_content": " Florence,"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " in Florence", "format": "unknown", "index": 0}], "reasoning_content": " in Florence"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " Italy.", "format": "unknown", "index": 0}], "reasoning_content": " Italy."}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ", Italy", "format": "unknown", "index": 0}], "reasoning_content": ", Italy"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " I have", "format": "unknown", "index": 0}], "reasoning_content": " I have"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ". I", "format": "unknown", "index": 0}], "reasoning_content": ". I"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " a weather", "format": "unknown", "index": 0}], "reasoning_content": " a weather"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " have", "format": "unknown", "index": 0}], "reasoning_content": " have"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " function available", "format": "unknown", "index": 0}], "reasoning_content": " function available"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " a weather", "format": "unknown", "index": 0}], "reasoning_content": " a weather"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " that can", "format": "unknown", "index": 0}], "reasoning_content": " that can"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " function available", "format": "unknown", "index": 0}], "reasoning_content": " function available"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " get", "format": "unknown", "index": 0}], "reasoning_content": " get"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " that can", "format": "unknown", "index": 0}], "reasoning_content": " that can"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " weather information", "format": "unknown", "index": 0}], "reasoning_content": " weather information"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " get", "format": "unknown", "index": 0}], "reasoning_content": " get"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " for a", "format": "unknown", "index": 0}], "reasoning_content": " for a"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " weather information", "format": "unknown", "index": 0}], "reasoning_content": " weather information"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " location.", "format": "unknown", "index": 0}], "reasoning_content": " location."}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " for a", "format": "unknown", "index": 0}], "reasoning_content": " for a"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " I need", "format": "unknown", "index": 0}], "reasoning_content": " I need"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " location.", "format": "unknown", "index": 0}], "reasoning_content": " location."}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " to call", "format": "unknown", "index": 0}], "reasoning_content": " to call"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " I need", "format": "unknown", "index": 0}], "reasoning_content": " I need"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " this", "format": "unknown", "index": 0}], "reasoning_content": " this"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " to call", "format": "unknown", "index": 0}], "reasoning_content": " to call"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " function with", "format": "unknown", "index": 0}], "reasoning_content": " function with"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " the weather", "format": "unknown", "index": 0}], "reasoning_content": " the weather"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " \"", "format": "unknown", "index": 0}], "reasoning_content": " \""}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " function with", "format": "unknown", "index": 0}], "reasoning_content": " function with"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "Flore", "format": "unknown", "index": 0}], "reasoning_content": "Flore"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " the location", "format": "unknown", "index": 0}], "reasoning_content": " the location"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "nce,", "format": "unknown", "index": 0}], "reasoning_content": "nce,"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " \"Fl", "format": "unknown", "index": 0}], "reasoning_content": " \"Fl"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "Italy", "format": "unknown", "index": 0}], "reasoning_content": "Italy"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "orence", "format": "unknown", "index": 0}], "reasoning_content": "orence"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "\" (", "format": "unknown", "index": 0}], "reasoning_content": "\" ("}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ",Italy", "format": "unknown", "index": 0}], "reasoning_content": ",Italy"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "or just", "format": "unknown", "index": 0}], "reasoning_content": "or just"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "\" or", "format": "unknown", "index": 0}], "reasoning_content": "\" or"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " \"Fl", "format": "unknown", "index": 0}], "reasoning_content": " \"Fl"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " just \"", "format": "unknown", "index": 0}], "reasoning_content": " just \""}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "orence", "format": "unknown", "index": 0}], "reasoning_content": "orence"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "Flore", "format": "unknown", "index": 0}], "reasoning_content": "Flore"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "\"", "format": "unknown", "index": 0}], "reasoning_content": "\""}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "nce\".\n\n", "format": "unknown", "index": 0}], "reasoning_content": "nce\".\n\n"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " since", "format": "unknown", "index": 0}], "reasoning_content": " since"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "Let me", "format": "unknown", "index": 0}], "reasoning_content": "Let me"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " Italy", "format": "unknown", "index": 0}], "reasoning_content": " Italy"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " call the", "format": "unknown", "index": 0}], "reasoning_content": " call the"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " is implied", "format": "unknown", "index": 0}], "reasoning_content": " is implied"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " function", "format": "unknown", "index": 0}], "reasoning_content": " function"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ") as", "format": "unknown", "index": 0}], "reasoning_content": ") as"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " to", "format": "unknown", "index": 0}], "reasoning_content": " to"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " the location", "format": "unknown", "index": 0}], "reasoning_content": " the location"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " get the", "format": "unknown", "index": 0}], "reasoning_content": " get the"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " parameter.\n\n", "format": "unknown", "index": 0}], "reasoning_content": " parameter.\n\n"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "Let me", "format": "unknown", "index": 0}], "reasoning_content": "Let me"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " weather information", "format": "unknown", "index": 0}], "reasoning_content": " weather information"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " call the", "format": "unknown", "index": 0}], "reasoning_content": " call the"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " weather function", "format": "unknown", "index": 0}], "reasoning_content": " weather function"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " with the", "format": "unknown", "index": 0}], "reasoning_content": " with the"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ". ", "format": "unknown", "index": 0}], "reasoning_content": ". "}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " location \"", "format": "unknown", "index": 0}], "reasoning_content": " location \""}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " I'll check", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "Flore", "format": "unknown", "index": 0}], "reasoning_content": "Flore"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " the weather", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "nce,", "format": "unknown", "index": 0}], "reasoning_content": "nce,"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " in", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "Italy\".", "format": "unknown", "index": 0}], "reasoning_content": "Italy\"."}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " Florence,", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " ", "format": "unknown", "index": 0}], "reasoning_content": " "}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " Italy for", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " ", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " you.", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " ", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " ", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": null, "role": "assistant", "tool_calls": [{"index": 0, "id": "functions.weather:0", "type": "function", "function": {"name": "weather", "arguments": ""}}]}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": null, "role": "assistant", "tool_calls": [{"index": 0, "function": {"arguments": "{\"location\": \"Florence,Italy\"}"}}]}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_content": null}, "finish_reason": "tool_calls", "native_finish_reason": "tool_calls"}]} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": null, "role": "assistant", "tool_calls": [{"index": 0, "id": "functions.weather:0", "type": "function", "function": {"name": "weather", "arguments": ""}}]}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932879-9itFTxmMNzDrUKQO1wmR", "object": "chat.completion.chunk", "created": 1773932879, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant"}, "finish_reason": "tool_calls", "native_finish_reason": "tool_calls"}], "usage": {"prompt_tokens": 76, "completion_tokens": 96, "total_tokens": 172, "cost": 0.000231, "prompt_tokens_details": {"cached_tokens": 64, "cache_write_tokens": 0, "audio_tokens": 0, "video_tokens": 0}, "completion_tokens_details": {"reasoning_tokens": 85, "image_tokens": 0, "audio_tokens": 0}}} + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": null, "role": "assistant", "tool_calls": [{"index": 0, "function": {"arguments": "{\"location\": \"Florence,Italy\"}"}}]}, "finish_reason": null, "native_finish_reason": null}]} + + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " ", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_content": null}, "finish_reason": "tool_calls", "native_finish_reason": "tool_calls"}]} + + data: {"id": "gen-1787940162-yQfU618ORS7gtYQhvAQW", "object": "chat.completion.chunk", "created": 1787940162, "model": "moonshotai/kimi-k2.5", "service_tier": null, "choices": [{"index": 0, "delta": {"content": "", "role": "assistant"}, "finish_reason": "tool_calls", "native_finish_reason": "tool_calls"}], "usage": {"prompt_tokens": 76, "completion_tokens": 94, "total_tokens": 170, "cost": 0.000241, "prompt_tokens_details": {"cached_tokens": 0, "cache_write_tokens": 0, "audio_tokens": 0, "video_tokens": 0}, "completion_tokens_details": {"reasoning_tokens": 74, "image_tokens": 0, "audio_tokens": 0}}} data: [DONE] @@ -131,22 +131,22 @@ interactions: - text/event-stream; charset=utf-8 status: 200 OK code: 200 - duration: 478.108041ms + duration: 397.612083ms - id: 1 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 - content_length: 1126 + content_length: 1137 host: "" - body: '{"messages":[{"content":"You are a helpful assistant","role":"system"},{"content":"What''s the weather in Florence,Italy?","role":"user"},{"content":" ","tool_calls":[{"id":"functions.weather:0","function":{"arguments":"{\"location\": \"Florence,Italy\"}","name":"weather"},"type":"function"}],"role":"assistant","reasoning_content":"The user is asking for the weather in Florence, Italy. I have a weather function available that can get weather information for a location. I need to call this function with \"Florence,Italy\" (or just \"Florence\" since Italy is implied) as the location parameter.\n\nLet me call the weather function with the location \"Florence,Italy\". "},{"content":"40 C","tool_call_id":"functions.weather:0","role":"tool"}],"model":"moonshotai/kimi-k2.5","max_tokens":4000,"stream_options":{"include_usage":true},"tool_choice":"auto","tools":[{"function":{"name":"weather","strict":false,"description":"Get weather information for a location","parameters":{"properties":{"location":{"description":"the city","type":"string"}},"required":["location"],"type":"object"}},"type":"function"}],"stream":true}' + body: '{"messages":[{"content":"You are a helpful assistant","role":"system"},{"content":"What''s the weather in Florence,Italy?","role":"user"},{"content":" I''ll check the weather in Florence, Italy for you. \n ","tool_calls":[{"id":"functions.weather:0","function":{"arguments":"{\"location\": \"Florence,Italy\"}","name":"weather"},"type":"function"}],"role":"assistant","reasoning_content":"The user is asking about the weather in Florence, Italy. I have a weather function available that can get weather information for a location. I need to call the weather function with the location \"Florence,Italy\" or just \"Florence\".\n\nLet me call the function to get the weather information. "},{"content":"40 C","tool_call_id":"functions.weather:0","role":"tool"}],"model":"moonshotai/kimi-k2.5","max_tokens":4000,"stream_options":{"include_usage":true},"tool_choice":"auto","tools":[{"function":{"name":"weather","strict":false,"description":"Get weather information for a location","parameters":{"properties":{"location":{"description":"the city","type":"string"}},"required":["location"],"type":"object"}},"type":"function"}],"stream":true}' headers: Accept: - application/json Content-Type: - application/json User-Agent: - - Charm-Fantasy/0.15.0 (https://charm.land/fantasy) + - Charm-Fantasy/0.41.3 (https://charm.land/fantasy) url: https://api.avian.io/v1/chat/completions method: POST response: @@ -155,204 +155,112 @@ interactions: proto_minor: 0 content_length: -1 body: |+ - data: {"id": "chatcmpl-f958b814284a4e529ecd5d41", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": null}]} - - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "The", "format": "unknown", "index": 0}], "reasoning_content": "The"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " function returned", "format": "unknown", "index": 0}], "reasoning_content": " function returned"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " \"", "format": "unknown", "index": 0}], "reasoning_content": " \""}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "40 C", "format": "unknown", "index": 0}], "reasoning_content": "40 C"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "\"", "format": "unknown", "index": 0}], "reasoning_content": "\""}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " which means", "format": "unknown", "index": 0}], "reasoning_content": " which means"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " 40 degrees", "format": "unknown", "index": 0}], "reasoning_content": " 40 degrees"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " Celsius.", "format": "unknown", "index": 0}], "reasoning_content": " Celsius."}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " This", "format": "unknown", "index": 0}], "reasoning_content": " This"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " is extremely", "format": "unknown", "index": 0}], "reasoning_content": " is extremely"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " hot -", "format": "unknown", "index": 0}], "reasoning_content": " hot -"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " almost", "format": "unknown", "index": 0}], "reasoning_content": " almost"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " dangerously", "format": "unknown", "index": 0}], "reasoning_content": " dangerously"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " hot", "format": "unknown", "index": 0}], "reasoning_content": " hot"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " for", "format": "unknown", "index": 0}], "reasoning_content": " for"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " a", "format": "unknown", "index": 0}], "reasoning_content": " a"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " city like", "format": "unknown", "index": 0}], "reasoning_content": " city like"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " Florence.", "format": "unknown", "index": 0}], "reasoning_content": " Florence."}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " But", "format": "unknown", "index": 0}], "reasoning_content": " But"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " this", "format": "unknown", "index": 0}], "reasoning_content": " this"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " is what", "format": "unknown", "index": 0}], "reasoning_content": " is what"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " the function", "format": "unknown", "index": 0}], "reasoning_content": " the function"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " returned,", "format": "unknown", "index": 0}], "reasoning_content": " returned,"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " so I", "format": "unknown", "index": 0}], "reasoning_content": " so I"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " should present", "format": "unknown", "index": 0}], "reasoning_content": " should present"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " this", "format": "unknown", "index": 0}], "reasoning_content": " this"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " information to", "format": "unknown", "index": 0}], "reasoning_content": " information to"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " the user", "format": "unknown", "index": 0}], "reasoning_content": " the user"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ". I", "format": "unknown", "index": 0}], "reasoning_content": ". I"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " should convert", "format": "unknown", "index": 0}], "reasoning_content": " should convert"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " it to", "format": "unknown", "index": 0}], "reasoning_content": " it to"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " Fahrenheit as", "format": "unknown", "index": 0}], "reasoning_content": " Fahrenheit as"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " well to", "format": "unknown", "index": 0}], "reasoning_content": " well to"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " make", "format": "unknown", "index": 0}], "reasoning_content": " make"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " it more", "format": "unknown", "index": 0}], "reasoning_content": " it more"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " accessible", "format": "unknown", "index": 0}], "reasoning_content": " accessible"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ", and", "format": "unknown", "index": 0}], "reasoning_content": ", and"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " perhaps", "format": "unknown", "index": 0}], "reasoning_content": " perhaps"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " note that", "format": "unknown", "index": 0}], "reasoning_content": " note that"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " it's", "format": "unknown", "index": 0}], "reasoning_content": " it's"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " very", "format": "unknown", "index": 0}], "reasoning_content": " very"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " hot.\n\n", "format": "unknown", "index": 0}], "reasoning_content": " hot.\n\n"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "40°C", "format": "unknown", "index": 0}], "reasoning_content": "40°C"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " = ", "format": "unknown", "index": 0}], "reasoning_content": " = "}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "chatcmpl-faab0996e9644e74974fc841", "object": "chat.completion.chunk", "created": 1787940169, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "104°F", "format": "unknown", "index": 0}], "reasoning_content": "104°F"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " (", "format": "unknown", "index": 0}], "reasoning_content": " ("}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "The", "format": "unknown", "index": 0}], "reasoning_content": "The"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "40", "format": "unknown", "index": 0}], "reasoning_content": "40"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " weather", "format": "unknown", "index": 0}], "reasoning_content": " weather"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " *", "format": "unknown", "index": 0}], "reasoning_content": " *"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " function returned", "format": "unknown", "index": 0}], "reasoning_content": " function returned"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " 9", "format": "unknown", "index": 0}], "reasoning_content": " 9"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " \"", "format": "unknown", "index": 0}], "reasoning_content": " \""}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "/5", "format": "unknown", "index": 0}], "reasoning_content": "/5"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "40 C", "format": "unknown", "index": 0}], "reasoning_content": "40 C"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " + ", "format": "unknown", "index": 0}], "reasoning_content": " + "}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "\".", "format": "unknown", "index": 0}], "reasoning_content": "\"."}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "32 =", "format": "unknown", "index": 0}], "reasoning_content": "32 ="}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " This is", "format": "unknown", "index": 0}], "reasoning_content": " This is"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " 72", "format": "unknown", "index": 0}], "reasoning_content": " 72"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " the", "format": "unknown", "index": 0}], "reasoning_content": " the"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " + ", "format": "unknown", "index": 0}], "reasoning_content": " + "}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " temperature in", "format": "unknown", "index": 0}], "reasoning_content": " temperature in"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "32 =", "format": "unknown", "index": 0}], "reasoning_content": "32 ="}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " Celsius", "format": "unknown", "index": 0}], "reasoning_content": " Celsius"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " 104", "format": "unknown", "index": 0}], "reasoning_content": " 104"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " in", "format": "unknown", "index": 0}], "reasoning_content": " in"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ")\n\nI'll", "format": "unknown", "index": 0}], "reasoning_content": ")\n\nI'll"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " Florence,", "format": "unknown", "index": 0}], "reasoning_content": " Florence,"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " present", "format": "unknown", "index": 0}], "reasoning_content": " present"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " Italy.", "format": "unknown", "index": 0}], "reasoning_content": " Italy."}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " this information", "format": "unknown", "index": 0}], "reasoning_content": " this information"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " 40°C", "format": "unknown", "index": 0}], "reasoning_content": " 40°C"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " clearly.", "format": "unknown", "index": 0}], "reasoning_content": " clearly."}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " is very", "format": "unknown", "index": 0}], "reasoning_content": " is very"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " ", "format": "unknown", "index": 0}], "reasoning_content": " "}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " hot -", "format": "unknown", "index": 0}], "reasoning_content": " hot -"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " The", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " that's about", "format": "unknown", "index": 0}], "reasoning_content": " that's about"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " weather in", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " 104", "format": "unknown", "index": 0}], "reasoning_content": " 104"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " Florence,", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " Italy is", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "°F.", "format": "unknown", "index": 0}], "reasoning_content": "°F."}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " **", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " I should", "format": "unknown", "index": 0}], "reasoning_content": " I should"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "40°C", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " present", "format": "unknown", "index": 0}], "reasoning_content": " present"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " (104", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " this information", "format": "unknown", "index": 0}], "reasoning_content": " this information"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "°F)**", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " to", "format": "unknown", "index": 0}], "reasoning_content": " to"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": ".\n\n", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " the user", "format": "unknown", "index": 0}], "reasoning_content": " the user"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "That's extremely", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " in", "format": "unknown", "index": 0}], "reasoning_content": " in"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " hot!", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " a helpful", "format": "unknown", "index": 0}], "reasoning_content": " a helpful"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " ☀️", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " If you're", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " way.", "format": "unknown", "index": 0}], "reasoning_content": " way."}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " visiting Florence", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " ", "format": "unknown", "index": 0}], "reasoning_content": " "}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": ", make", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " The", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " sure to", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " current", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " stay hydrated", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " weather in", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": ", seek", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " Florence,", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " shade during", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " Italy is", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " the midday", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " **40", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " hours,", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "°C**", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " and wear", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " (104", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " sun", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "°F).", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " protection.", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " That's quite", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " Temperature", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " hot!", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "s this", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " ☀️", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " high are", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "\n\nMake", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " typical", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " sure to", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " of", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " stay hydrated", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " peak", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " and protect", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " summer", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " yourself from", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " heat waves", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " the sun", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " in the", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " if you're", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " region", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " visiting today", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": ".", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": ".", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_content": null}, "finish_reason": "stop", "native_finish_reason": "stop"}]} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_content": null}, "finish_reason": "stop", "native_finish_reason": "stop"}]} - data: {"id": "gen-1773932881-AW4KeKUZwBMqmOdcdJ6Q", "object": "chat.completion.chunk", "created": 1773932881, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant"}, "finish_reason": "stop", "native_finish_reason": "stop"}], "usage": {"prompt_tokens": 194, "completion_tokens": 161, "total_tokens": 355, "cost": 0.0004127, "prompt_tokens_details": {"cached_tokens": 128, "cache_write_tokens": 0, "audio_tokens": 0, "video_tokens": 0}, "completion_tokens_details": {"reasoning_tokens": 109, "image_tokens": 0, "audio_tokens": 0}}} + data: {"id": "gen-1787940168-sdQPAGE5xligrqHsosoJ", "object": "chat.completion.chunk", "created": 1787940168, "model": "moonshotai/kimi-k2.5", "service_tier": null, "choices": [{"index": 0, "delta": {"content": "", "role": "assistant"}, "finish_reason": "stop", "native_finish_reason": "stop"}], "usage": {"prompt_tokens": 193, "completion_tokens": 86, "total_tokens": 279, "cost": 0.00024725, "prompt_tokens_details": {"cached_tokens": 128, "cache_write_tokens": 0, "audio_tokens": 0, "video_tokens": 0}, "completion_tokens_details": {"reasoning_tokens": 50, "image_tokens": 0, "audio_tokens": 0}}} data: [DONE] @@ -361,4 +269,4 @@ interactions: - text/event-stream; charset=utf-8 status: 200 OK code: 200 - duration: 336.129584ms + duration: 361.191791ms diff --git a/providertests/testdata/TestOpenAICompatibleThinking/avian-kimi/thinking-streaming.yaml b/providertests/testdata/TestOpenAICompatibleThinking/avian-kimi/thinking-streaming.yaml index a4aaa5a3a..c3fda5978 100644 --- a/providertests/testdata/TestOpenAICompatibleThinking/avian-kimi/thinking-streaming.yaml +++ b/providertests/testdata/TestOpenAICompatibleThinking/avian-kimi/thinking-streaming.yaml @@ -15,7 +15,7 @@ interactions: Content-Type: - application/json User-Agent: - - Charm-Fantasy/0.15.0 (https://charm.land/fantasy) + - Charm-Fantasy/0.41.3 (https://charm.land/fantasy) url: https://api.avian.io/v1/chat/completions method: POST response: @@ -24,56 +24,109 @@ interactions: proto_minor: 0 content_length: -1 body: |+ - data: {"id": "chatcmpl-6e0335f41a00481da298cc3f", "object": "chat.completion.chunk", "created": 1773932889, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": null}]} + data: {"id": "chatcmpl-37626b4b1a144f659fcb086e", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": null}]} - data: {"id": "gen-1773932889-tco5qSg4D8Z94K6GPhdr", "object": "chat.completion.chunk", "created": 1773932889, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "I", "format": "unknown", "index": 0}], "reasoning_content": "I"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "The", "format": "unknown", "index": 0}], "reasoning_content": "The"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932889-tco5qSg4D8Z94K6GPhdr", "object": "chat.completion.chunk", "created": 1773932889, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " need to", "format": "unknown", "index": 0}], "reasoning_content": " need to"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " user is", "format": "unknown", "index": 0}], "reasoning_content": " user is"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932889-tco5qSg4D8Z94K6GPhdr", "object": "chat.completion.chunk", "created": 1773932889, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " get the", "format": "unknown", "index": 0}], "reasoning_content": " get the"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " asking for", "format": "unknown", "index": 0}], "reasoning_content": " asking for"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932889-tco5qSg4D8Z94K6GPhdr", "object": "chat.completion.chunk", "created": 1773932889, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " weather information", "format": "unknown", "index": 0}], "reasoning_content": " weather information"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " the weather", "format": "unknown", "index": 0}], "reasoning_content": " the weather"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932889-tco5qSg4D8Z94K6GPhdr", "object": "chat.completion.chunk", "created": 1773932889, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " for Florence", "format": "unknown", "index": 0}], "reasoning_content": " for Florence"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " in Florence", "format": "unknown", "index": 0}], "reasoning_content": " in Florence"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932889-tco5qSg4D8Z94K6GPhdr", "object": "chat.completion.chunk", "created": 1773932889, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ", Italy", "format": "unknown", "index": 0}], "reasoning_content": ", Italy"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ", Italy", "format": "unknown", "index": 0}], "reasoning_content": ", Italy"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932889-tco5qSg4D8Z94K6GPhdr", "object": "chat.completion.chunk", "created": 1773932889, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ". I'll", "format": "unknown", "index": 0}], "reasoning_content": ". I'll"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ". I", "format": "unknown", "index": 0}], "reasoning_content": ". I"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932889-tco5qSg4D8Z94K6GPhdr", "object": "chat.completion.chunk", "created": 1773932889, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " call", "format": "unknown", "index": 0}], "reasoning_content": " call"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " have", "format": "unknown", "index": 0}], "reasoning_content": " have"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932889-tco5qSg4D8Z94K6GPhdr", "object": "chat.completion.chunk", "created": 1773932889, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " the weather", "format": "unknown", "index": 0}], "reasoning_content": " the weather"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " access", "format": "unknown", "index": 0}], "reasoning_content": " access"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932889-tco5qSg4D8Z94K6GPhdr", "object": "chat.completion.chunk", "created": 1773932889, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " function with", "format": "unknown", "index": 0}], "reasoning_content": " function with"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " to a", "format": "unknown", "index": 0}], "reasoning_content": " to a"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932889-tco5qSg4D8Z94K6GPhdr", "object": "chat.completion.chunk", "created": 1773932889, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " \"", "format": "unknown", "index": 0}], "reasoning_content": " \""}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " weather function", "format": "unknown", "index": 0}], "reasoning_content": " weather function"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932889-tco5qSg4D8Z94K6GPhdr", "object": "chat.completion.chunk", "created": 1773932889, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "Fl", "format": "unknown", "index": 0}], "reasoning_content": "Fl"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " that can", "format": "unknown", "index": 0}], "reasoning_content": " that can"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932889-tco5qSg4D8Z94K6GPhdr", "object": "chat.completion.chunk", "created": 1773932889, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "orence", "format": "unknown", "index": 0}], "reasoning_content": "orence"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " get", "format": "unknown", "index": 0}], "reasoning_content": " get"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932889-tco5qSg4D8Z94K6GPhdr", "object": "chat.completion.chunk", "created": 1773932889, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "\"", "format": "unknown", "index": 0}], "reasoning_content": "\""}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " weather information", "format": "unknown", "index": 0}], "reasoning_content": " weather information"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932889-tco5qSg4D8Z94K6GPhdr", "object": "chat.completion.chunk", "created": 1773932889, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " as the", "format": "unknown", "index": 0}], "reasoning_content": " as the"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " for a", "format": "unknown", "index": 0}], "reasoning_content": " for a"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932889-tco5qSg4D8Z94K6GPhdr", "object": "chat.completion.chunk", "created": 1773932889, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " location parameter", "format": "unknown", "index": 0}], "reasoning_content": " location parameter"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " location.", "format": "unknown", "index": 0}], "reasoning_content": " location."}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932889-tco5qSg4D8Z94K6GPhdr", "object": "chat.completion.chunk", "created": 1773932889, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ". ", "format": "unknown", "index": 0}], "reasoning_content": ". "}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " I need", "format": "unknown", "index": 0}], "reasoning_content": " I need"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932889-tco5qSg4D8Z94K6GPhdr", "object": "chat.completion.chunk", "created": 1773932889, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " ", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " to call", "format": "unknown", "index": 0}], "reasoning_content": " to call"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932889-tco5qSg4D8Z94K6GPhdr", "object": "chat.completion.chunk", "created": 1773932889, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " ", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " this", "format": "unknown", "index": 0}], "reasoning_content": " this"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932889-tco5qSg4D8Z94K6GPhdr", "object": "chat.completion.chunk", "created": 1773932889, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": null, "role": "assistant", "tool_calls": [{"index": 0, "id": "functions.weather:0", "type": "function", "function": {"name": "weather", "arguments": ""}}]}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " function with", "format": "unknown", "index": 0}], "reasoning_content": " function with"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932889-tco5qSg4D8Z94K6GPhdr", "object": "chat.completion.chunk", "created": 1773932889, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": null, "role": "assistant", "tool_calls": [{"index": 0, "function": {"arguments": "{\"location\": \"Florence\"}"}}]}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " the location", "format": "unknown", "index": 0}], "reasoning_content": " the location"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932889-tco5qSg4D8Z94K6GPhdr", "object": "chat.completion.chunk", "created": 1773932889, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " ", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " parameter", "format": "unknown", "index": 0}], "reasoning_content": " parameter"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932889-tco5qSg4D8Z94K6GPhdr", "object": "chat.completion.chunk", "created": 1773932889, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_content": null}, "finish_reason": "tool_calls", "native_finish_reason": "tool_calls"}]} + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " set", "format": "unknown", "index": 0}], "reasoning_content": " set"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932889-tco5qSg4D8Z94K6GPhdr", "object": "chat.completion.chunk", "created": 1773932889, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant"}, "finish_reason": "tool_calls", "native_finish_reason": "tool_calls"}], "usage": {"prompt_tokens": 76, "completion_tokens": 47, "total_tokens": 123, "cost": 0.0001232, "prompt_tokens_details": {"cached_tokens": 64, "cache_write_tokens": 0, "audio_tokens": 0, "video_tokens": 0}, "completion_tokens_details": {"reasoning_tokens": 34, "image_tokens": 0, "audio_tokens": 0}}} + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " to \"", "format": "unknown", "index": 0}], "reasoning_content": " to \""}, "finish_reason": null, "native_finish_reason": null}]} + + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "Flore", "format": "unknown", "index": 0}], "reasoning_content": "Flore"}, "finish_reason": null, "native_finish_reason": null}]} + + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "nce\"", "format": "unknown", "index": 0}], "reasoning_content": "nce\""}, "finish_reason": null, "native_finish_reason": null}]} + + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " (", "format": "unknown", "index": 0}], "reasoning_content": " ("}, "finish_reason": null, "native_finish_reason": null}]} + + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "or \"", "format": "unknown", "index": 0}], "reasoning_content": "or \""}, "finish_reason": null, "native_finish_reason": null}]} + + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "Flore", "format": "unknown", "index": 0}], "reasoning_content": "Flore"}, "finish_reason": null, "native_finish_reason": null}]} + + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "nce,", "format": "unknown", "index": 0}], "reasoning_content": "nce,"}, "finish_reason": null, "native_finish_reason": null}]} + + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " Italy\")", "format": "unknown", "index": 0}], "reasoning_content": " Italy\")"}, "finish_reason": null, "native_finish_reason": null}]} + + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ".\n\nLet", "format": "unknown", "index": 0}], "reasoning_content": ".\n\nLet"}, "finish_reason": null, "native_finish_reason": null}]} + + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " me call", "format": "unknown", "index": 0}], "reasoning_content": " me call"}, "finish_reason": null, "native_finish_reason": null}]} + + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " the weather", "format": "unknown", "index": 0}], "reasoning_content": " the weather"}, "finish_reason": null, "native_finish_reason": null}]} + + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " function with", "format": "unknown", "index": 0}], "reasoning_content": " function with"}, "finish_reason": null, "native_finish_reason": null}]} + + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " the location", "format": "unknown", "index": 0}], "reasoning_content": " the location"}, "finish_reason": null, "native_finish_reason": null}]} + + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ".", "format": "unknown", "index": 0}], "reasoning_content": "."}, "finish_reason": null, "native_finish_reason": null}]} + + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " ", "format": "unknown", "index": 0}], "reasoning_content": " "}, "finish_reason": null, "native_finish_reason": null}]} + + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " I'll", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " check", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " the weather", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " in", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " Florence for", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " you.", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " ", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + + + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": null, "role": "assistant", "tool_calls": [{"index": 0, "id": "functions.weather:0", "type": "function", "function": {"name": "weather", "arguments": ""}}]}, "finish_reason": null, "native_finish_reason": null}]} + + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": null, "role": "assistant", "tool_calls": [{"index": 0, "function": {"arguments": "{\"location\": \"Florence, Italy\"}"}}]}, "finish_reason": null, "native_finish_reason": null}]} + + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " ", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_content": null}, "finish_reason": "tool_calls", "native_finish_reason": "tool_calls"}]} + + data: {"id": "gen-1787940185-fmDZ01VEzSOlXmmR1Zxr", "object": "chat.completion.chunk", "created": 1787940185, "model": "moonshotai/kimi-k2.5", "service_tier": null, "choices": [{"index": 0, "delta": {"content": "", "role": "assistant"}, "finish_reason": "tool_calls", "native_finish_reason": "tool_calls"}], "usage": {"prompt_tokens": 76, "completion_tokens": 95, "total_tokens": 171, "cost": 0.0002288, "prompt_tokens_details": {"cached_tokens": 64, "cache_write_tokens": 0, "audio_tokens": 0, "video_tokens": 0}, "completion_tokens_details": {"reasoning_tokens": 74, "image_tokens": 0, "audio_tokens": 0}}} data: [DONE] @@ -82,22 +135,22 @@ interactions: - text/event-stream; charset=utf-8 status: 200 OK code: 200 - duration: 425.380292ms + duration: 521.325459ms - id: 1 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 - content_length: 897 + content_length: 1115 host: "" - body: '{"messages":[{"content":"You are a helpful assistant","role":"system"},{"content":"What''s the weather in Florence, Italy?","role":"user"},{"content":" ","tool_calls":[{"id":"functions.weather:0","function":{"arguments":"{\"location\": \"Florence\"}","name":"weather"},"type":"function"}],"role":"assistant","reasoning_content":"I need to get the weather information for Florence, Italy. I''ll call the weather function with \"Florence\" as the location parameter. "},{"content":"40 C","tool_call_id":"functions.weather:0","role":"tool"}],"model":"moonshotai/kimi-k2.5","stream_options":{"include_usage":true},"tool_choice":"auto","tools":[{"function":{"name":"weather","strict":false,"description":"Get weather information for a location","parameters":{"properties":{"location":{"description":"the city","type":"string"}},"required":["location"],"type":"object"}},"type":"function"}],"stream":true}' + body: '{"messages":[{"content":"You are a helpful assistant","role":"system"},{"content":"What''s the weather in Florence, Italy?","role":"user"},{"content":" I''ll check the weather in Florence for you. \n ","tool_calls":[{"id":"functions.weather:0","function":{"arguments":"{\"location\": \"Florence, Italy\"}","name":"weather"},"type":"function"}],"role":"assistant","reasoning_content":"The user is asking for the weather in Florence, Italy. I have access to a weather function that can get weather information for a location. I need to call this function with the location parameter set to \"Florence\" (or \"Florence, Italy\").\n\nLet me call the weather function with the location. "},{"content":"40 C","tool_call_id":"functions.weather:0","role":"tool"}],"model":"moonshotai/kimi-k2.5","stream_options":{"include_usage":true},"tool_choice":"auto","tools":[{"function":{"name":"weather","strict":false,"description":"Get weather information for a location","parameters":{"properties":{"location":{"description":"the city","type":"string"}},"required":["location"],"type":"object"}},"type":"function"}],"stream":true}' headers: Accept: - application/json Content-Type: - application/json User-Agent: - - Charm-Fantasy/0.15.0 (https://charm.land/fantasy) + - Charm-Fantasy/0.41.3 (https://charm.land/fantasy) url: https://api.avian.io/v1/chat/completions method: POST response: @@ -106,101 +159,79 @@ interactions: proto_minor: 0 content_length: -1 body: |+ - data: {"id": "chatcmpl-6134a71fef4d44bfa96e800f", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": null}]} - - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "The", "format": "unknown", "index": 0}], "reasoning_content": "The"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " weather", "format": "unknown", "index": 0}], "reasoning_content": " weather"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " function returned", "format": "unknown", "index": 0}], "reasoning_content": " function returned"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " \"", "format": "unknown", "index": 0}], "reasoning_content": " \""}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "40 C", "format": "unknown", "index": 0}], "reasoning_content": "40 C"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "\" which", "format": "unknown", "index": 0}], "reasoning_content": "\" which"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " means", "format": "unknown", "index": 0}], "reasoning_content": " means"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " it's", "format": "unknown", "index": 0}], "reasoning_content": " it's"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " 40", "format": "unknown", "index": 0}], "reasoning_content": " 40"}, "finish_reason": null, "native_finish_reason": null}]} - - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " degrees Celsius", "format": "unknown", "index": 0}], "reasoning_content": " degrees Celsius"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "chatcmpl-e05ae3975053440d93bd0e60", "object": "chat.completion.chunk", "created": 1787940189, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": null}]} - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " in Florence", "format": "unknown", "index": 0}], "reasoning_content": " in Florence"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ". This", "format": "unknown", "index": 0}], "reasoning_content": ". This"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940189-rJx2kxF5pRCtvrqovqn3", "object": "chat.completion.chunk", "created": 1787940189, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "The", "format": "unknown", "index": 0}], "reasoning_content": "The"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " is quite", "format": "unknown", "index": 0}], "reasoning_content": " is quite"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940189-rJx2kxF5pRCtvrqovqn3", "object": "chat.completion.chunk", "created": 1787940189, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " weather", "format": "unknown", "index": 0}], "reasoning_content": " weather"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " hot!", "format": "unknown", "index": 0}], "reasoning_content": " hot!"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940189-rJx2kxF5pRCtvrqovqn3", "object": "chat.completion.chunk", "created": 1787940189, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " in", "format": "unknown", "index": 0}], "reasoning_content": " in"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " I should", "format": "unknown", "index": 0}], "reasoning_content": " I should"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " report", "format": "unknown", "index": 0}], "reasoning_content": " report"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940189-rJx2kxF5pRCtvrqovqn3", "object": "chat.completion.chunk", "created": 1787940189, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " Florence,", "format": "unknown", "index": 0}], "reasoning_content": " Florence,"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " this to", "format": "unknown", "index": 0}], "reasoning_content": " this to"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940189-rJx2kxF5pRCtvrqovqn3", "object": "chat.completion.chunk", "created": 1787940189, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " Italy is", "format": "unknown", "index": 0}], "reasoning_content": " Italy is"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " the user", "format": "unknown", "index": 0}], "reasoning_content": " the user"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940189-rJx2kxF5pRCtvrqovqn3", "object": "chat.completion.chunk", "created": 1787940189, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " 40", "format": "unknown", "index": 0}], "reasoning_content": " 40"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " and", "format": "unknown", "index": 0}], "reasoning_content": " and"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940189-rJx2kxF5pRCtvrqovqn3", "object": "chat.completion.chunk", "created": 1787940189, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": "°C.", "format": "unknown", "index": 0}], "reasoning_content": "°C."}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " perhaps convert", "format": "unknown", "index": 0}], "reasoning_content": " perhaps convert"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940189-rJx2kxF5pRCtvrqovqn3", "object": "chat.completion.chunk", "created": 1787940189, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " That's quite", "format": "unknown", "index": 0}], "reasoning_content": " That's quite"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " it to", "format": "unknown", "index": 0}], "reasoning_content": " it to"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940189-rJx2kxF5pRCtvrqovqn3", "object": "chat.completion.chunk", "created": 1787940189, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " hot!", "format": "unknown", "index": 0}], "reasoning_content": " hot!"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " Fahrenheit as", "format": "unknown", "index": 0}], "reasoning_content": " Fahrenheit as"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940189-rJx2kxF5pRCtvrqovqn3", "object": "chat.completion.chunk", "created": 1787940189, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " I should", "format": "unknown", "index": 0}], "reasoning_content": " I should"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " well for", "format": "unknown", "index": 0}], "reasoning_content": " well for"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940189-rJx2kxF5pRCtvrqovqn3", "object": "chat.completion.chunk", "created": 1787940189, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " present", "format": "unknown", "index": 0}], "reasoning_content": " present"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " better", "format": "unknown", "index": 0}], "reasoning_content": " better"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940189-rJx2kxF5pRCtvrqovqn3", "object": "chat.completion.chunk", "created": 1787940189, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " this information", "format": "unknown", "index": 0}], "reasoning_content": " this information"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " context", "format": "unknown", "index": 0}], "reasoning_content": " context"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940189-rJx2kxF5pRCtvrqovqn3", "object": "chat.completion.chunk", "created": 1787940189, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " clearly to", "format": "unknown", "index": 0}], "reasoning_content": " clearly to"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ". ", "format": "unknown", "index": 0}], "reasoning_content": ". "}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940189-rJx2kxF5pRCtvrqovqn3", "object": "chat.completion.chunk", "created": 1787940189, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": " the user", "format": "unknown", "index": 0}], "reasoning_content": " the user"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " The", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940189-rJx2kxF5pRCtvrqovqn3", "object": "chat.completion.chunk", "created": 1787940189, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_details": [{"type": "reasoning.text", "text": ". ", "format": "unknown", "index": 0}], "reasoning_content": ". "}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " weather in", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940189-rJx2kxF5pRCtvrqovqn3", "object": "chat.completion.chunk", "created": 1787940189, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " The current", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " Florence,", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940189-rJx2kxF5pRCtvrqovqn3", "object": "chat.completion.chunk", "created": 1787940189, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " temperature", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " Italy is", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940189-rJx2kxF5pRCtvrqovqn3", "object": "chat.completion.chunk", "created": 1787940189, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " in Florence", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " currently **", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940189-rJx2kxF5pRCtvrqovqn3", "object": "chat.completion.chunk", "created": 1787940189, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": ", Italy", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "40°C", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940189-rJx2kxF5pRCtvrqovqn3", "object": "chat.completion.chunk", "created": 1787940189, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " is **", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " (", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940189-rJx2kxF5pRCtvrqovqn3", "object": "chat.completion.chunk", "created": 1787940189, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "40°C", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "104°F", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940189-rJx2kxF5pRCtvrqovqn3", "object": "chat.completion.chunk", "created": 1787940189, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "** (", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": ")** -", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940189-rJx2kxF5pRCtvrqovqn3", "object": "chat.completion.chunk", "created": 1787940189, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "104°F", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " that's", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940189-rJx2kxF5pRCtvrqovqn3", "object": "chat.completion.chunk", "created": 1787940189, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "). That's", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " quite hot", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940189-rJx2kxF5pRCtvrqovqn3", "object": "chat.completion.chunk", "created": 1787940189, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " quite hot", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "! Make", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940189-rJx2kxF5pRCtvrqovqn3", "object": "chat.completion.chunk", "created": 1787940189, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "! Make", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " sure to", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940189-rJx2kxF5pRCtvrqovqn3", "object": "chat.completion.chunk", "created": 1787940189, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " sure to", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " stay hydrated", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940189-rJx2kxF5pRCtvrqovqn3", "object": "chat.completion.chunk", "created": 1787940189, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " stay hydrated", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " and take", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940189-rJx2kxF5pRCtvrqovqn3", "object": "chat.completion.chunk", "created": 1787940189, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " and seek", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " precautions if", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940189-rJx2kxF5pRCtvrqovqn3", "object": "chat.completion.chunk", "created": 1787940189, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " shade if", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " you're planning", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940189-rJx2kxF5pRCtvrqovqn3", "object": "chat.completion.chunk", "created": 1787940189, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " you're visiting", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " to be", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940189-rJx2kxF5pRCtvrqovqn3", "object": "chat.completion.chunk", "created": 1787940189, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " the city", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " outdoors.", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} + data: {"id": "gen-1787940189-rJx2kxF5pRCtvrqovqn3", "object": "chat.completion.chunk", "created": 1787940189, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": " today.", "role": "assistant"}, "finish_reason": null, "native_finish_reason": null}]} - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_content": null}, "finish_reason": "stop", "native_finish_reason": "stop"}]} + data: {"id": "gen-1787940189-rJx2kxF5pRCtvrqovqn3", "object": "chat.completion.chunk", "created": 1787940189, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant", "reasoning_content": null}, "finish_reason": "stop", "native_finish_reason": "stop"}]} - data: {"id": "gen-1773932891-itZRH9Z0JMWEfAojMrcI", "object": "chat.completion.chunk", "created": 1773932891, "model": "moonshotai/kimi-k2.5", "choices": [{"index": 0, "delta": {"content": "", "role": "assistant"}, "finish_reason": "stop", "native_finish_reason": "stop"}], "usage": {"prompt_tokens": 145, "completion_tokens": 78, "total_tokens": 223, "cost": 0.00022245, "prompt_tokens_details": {"cached_tokens": 64, "cache_write_tokens": 0, "audio_tokens": 0, "video_tokens": 0}, "completion_tokens_details": {"reasoning_tokens": 51, "image_tokens": 0, "audio_tokens": 0}}} + data: {"id": "gen-1787940189-rJx2kxF5pRCtvrqovqn3", "object": "chat.completion.chunk", "created": 1787940189, "model": "moonshotai/kimi-k2.5", "service_tier": null, "choices": [{"index": 0, "delta": {"content": "", "role": "assistant"}, "finish_reason": "stop", "native_finish_reason": "stop"}], "usage": {"prompt_tokens": 194, "completion_tokens": 61, "total_tokens": 255, "cost": 0.0001927, "prompt_tokens_details": {"cached_tokens": 128, "cache_write_tokens": 0, "audio_tokens": 0, "video_tokens": 0}, "completion_tokens_details": {"reasoning_tokens": 29, "image_tokens": 0, "audio_tokens": 0}}} data: [DONE] @@ -209,4 +240,4 @@ interactions: - text/event-stream; charset=utf-8 status: 200 OK code: 200 - duration: 417.109875ms + duration: 349.110209ms diff --git a/providertests/testdata/TestOpenRouterCommonWithAnthropicCache/anthropic/multi_tool.yaml b/providertests/testdata/TestOpenRouterCommonWithAnthropicCache/anthropic/multi_tool.yaml index 4b5e30944..bec9618ad 100644 --- a/providertests/testdata/TestOpenRouterCommonWithAnthropicCache/anthropic/multi_tool.yaml +++ b/providertests/testdata/TestOpenRouterCommonWithAnthropicCache/anthropic/multi_tool.yaml @@ -36,9 +36,9 @@ interactions: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 - content_length: 1385 + content_length: 1422 host: "" - body: '{"messages":[{"content":"You are a helpful assistant. CRITICAL: Always use both add and multiply at the same time ALWAYS.","role":"system","cache_control":{"type":"ephemeral"}},{"content":"Add and multiply the number 2 and 3","role":"user"},{"tool_calls":[{"id":"toolu_bdrk_011gzTohxPFN5kvawEZfCgDs","function":{"arguments":"{\"a\": 2, \"b\": 3}","name":"add"},"type":"function"},{"id":"toolu_bdrk_013ddv4UW7UB3M9EvYTGzg3L","function":{"arguments":"{\"a\": 2, \"b\": 3}","name":"multiply"},"type":"function","cache_control":{"type":"ephemeral"}}],"role":"assistant"},{"content":"5","tool_call_id":"toolu_bdrk_011gzTohxPFN5kvawEZfCgDs","role":"tool"},{"content":"6","tool_call_id":"toolu_bdrk_013ddv4UW7UB3M9EvYTGzg3L","role":"tool"}],"model":"anthropic/claude-haiku-4.5","max_tokens":4000,"tool_choice":"auto","tools":[{"function":{"name":"add","strict":false,"description":"Add two numbers","parameters":{"properties":{"a":{"description":"first number","type":"integer"},"b":{"description":"second number","type":"integer"}},"required":["a","b"],"type":"object"}},"type":"function"},{"function":{"name":"multiply","strict":false,"description":"Multiply two numbers","parameters":{"properties":{"a":{"description":"first number","type":"integer"},"b":{"description":"second number","type":"integer"}},"required":["a","b"],"type":"object"}},"type":"function"}],"usage":{"include":true}}' + body: '{"messages":[{"content":"You are a helpful assistant. CRITICAL: Always use both add and multiply at the same time ALWAYS.","role":"system","cache_control":{"type":"ephemeral"}},{"content":"Add and multiply the number 2 and 3","role":"user"},{"tool_calls":[{"id":"toolu_bdrk_011gzTohxPFN5kvawEZfCgDs","function":{"arguments":"{\"a\": 2, \"b\": 3}","name":"add"},"type":"function"},{"id":"toolu_bdrk_013ddv4UW7UB3M9EvYTGzg3L","function":{"arguments":"{\"a\": 2, \"b\": 3}","name":"multiply"},"type":"function","cache_control":{"type":"ephemeral"}}],"role":"assistant"},{"content":"5","tool_call_id":"toolu_bdrk_011gzTohxPFN5kvawEZfCgDs","role":"tool"},{"content":"6","tool_call_id":"toolu_bdrk_013ddv4UW7UB3M9EvYTGzg3L","role":"tool","cache_control":{"type":"ephemeral"}}],"model":"anthropic/claude-haiku-4.5","max_tokens":4000,"tool_choice":"auto","tools":[{"function":{"name":"add","strict":false,"description":"Add two numbers","parameters":{"properties":{"a":{"description":"first number","type":"integer"},"b":{"description":"second number","type":"integer"}},"required":["a","b"],"type":"object"}},"type":"function"},{"function":{"name":"multiply","strict":false,"description":"Multiply two numbers","parameters":{"properties":{"a":{"description":"first number","type":"integer"},"b":{"description":"second number","type":"integer"}},"required":["a","b"],"type":"object"}},"type":"function"}],"usage":{"include":true}}' headers: Accept: - application/json diff --git a/providertests/testdata/TestOpenRouterCommonWithAnthropicCache/anthropic/multi_tool_streaming.yaml b/providertests/testdata/TestOpenRouterCommonWithAnthropicCache/anthropic/multi_tool_streaming.yaml index 16d6b1090..946b96694 100644 --- a/providertests/testdata/TestOpenRouterCommonWithAnthropicCache/anthropic/multi_tool_streaming.yaml +++ b/providertests/testdata/TestOpenRouterCommonWithAnthropicCache/anthropic/multi_tool_streaming.yaml @@ -77,9 +77,9 @@ interactions: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 - content_length: 1422 + content_length: 1459 host: "" - body: '{"messages":[{"content":"You are a helpful assistant. Always use both add and multiply at the same time.","role":"system","cache_control":{"type":"ephemeral"}},{"content":"Add and multiply the number 2 and 3","role":"user"},{"tool_calls":[{"id":"toolu_bdrk_01FS6PPeAPQTDNqXK3a3eRHr","function":{"arguments":"{\"a\": 2, \"b\": 3}","name":"add"},"type":"function"},{"id":"toolu_bdrk_01YRxwgSi9gzNNtQ9Bz5AX6u","function":{"arguments":"{\"a\": 2, \"b\": 3}","name":"multiply"},"type":"function","cache_control":{"type":"ephemeral"}}],"role":"assistant"},{"content":"5","tool_call_id":"toolu_bdrk_01FS6PPeAPQTDNqXK3a3eRHr","role":"tool"},{"content":"6","tool_call_id":"toolu_bdrk_01YRxwgSi9gzNNtQ9Bz5AX6u","role":"tool"}],"model":"anthropic/claude-haiku-4.5","max_tokens":4000,"stream_options":{"include_usage":true},"tool_choice":"auto","tools":[{"function":{"name":"add","strict":false,"description":"Add two numbers","parameters":{"properties":{"a":{"description":"first number","type":"integer"},"b":{"description":"second number","type":"integer"}},"required":["a","b"],"type":"object"}},"type":"function"},{"function":{"name":"multiply","strict":false,"description":"Multiply two numbers","parameters":{"properties":{"a":{"description":"first number","type":"integer"},"b":{"description":"second number","type":"integer"}},"required":["a","b"],"type":"object"}},"type":"function"}],"usage":{"include":true},"stream":true}' + body: '{"messages":[{"content":"You are a helpful assistant. Always use both add and multiply at the same time.","role":"system","cache_control":{"type":"ephemeral"}},{"content":"Add and multiply the number 2 and 3","role":"user"},{"tool_calls":[{"id":"toolu_bdrk_01FS6PPeAPQTDNqXK3a3eRHr","function":{"arguments":"{\"a\": 2, \"b\": 3}","name":"add"},"type":"function"},{"id":"toolu_bdrk_01YRxwgSi9gzNNtQ9Bz5AX6u","function":{"arguments":"{\"a\": 2, \"b\": 3}","name":"multiply"},"type":"function","cache_control":{"type":"ephemeral"}}],"role":"assistant"},{"content":"5","tool_call_id":"toolu_bdrk_01FS6PPeAPQTDNqXK3a3eRHr","role":"tool"},{"content":"6","tool_call_id":"toolu_bdrk_01YRxwgSi9gzNNtQ9Bz5AX6u","role":"tool","cache_control":{"type":"ephemeral"}}],"model":"anthropic/claude-haiku-4.5","max_tokens":4000,"stream_options":{"include_usage":true},"tool_choice":"auto","tools":[{"function":{"name":"add","strict":false,"description":"Add two numbers","parameters":{"properties":{"a":{"description":"first number","type":"integer"},"b":{"description":"second number","type":"integer"}},"required":["a","b"],"type":"object"}},"type":"function"},{"function":{"name":"multiply","strict":false,"description":"Multiply two numbers","parameters":{"properties":{"a":{"description":"first number","type":"integer"},"b":{"description":"second number","type":"integer"}},"required":["a","b"],"type":"object"}},"type":"function"}],"usage":{"include":true},"stream":true}' headers: Accept: - application/json diff --git a/providertests/testdata/TestOpenRouterCommonWithAnthropicCache/anthropic/tool.yaml b/providertests/testdata/TestOpenRouterCommonWithAnthropicCache/anthropic/tool.yaml index 83cc54104..88996fc74 100644 --- a/providertests/testdata/TestOpenRouterCommonWithAnthropicCache/anthropic/tool.yaml +++ b/providertests/testdata/TestOpenRouterCommonWithAnthropicCache/anthropic/tool.yaml @@ -36,9 +36,9 @@ interactions: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 - content_length: 832 + content_length: 869 host: "" - body: '{"messages":[{"content":"You are a helpful assistant","role":"system","cache_control":{"type":"ephemeral"}},{"content":"What''s the weather in Florence,Italy?","role":"user"},{"tool_calls":[{"id":"toolu_bdrk_0156sNzvRkpm2xuxFNs2N49s","function":{"arguments":"{\"location\": \"Florence, Italy\"}","name":"weather"},"type":"function","cache_control":{"type":"ephemeral"}}],"role":"assistant"},{"content":"40 C","tool_call_id":"toolu_bdrk_0156sNzvRkpm2xuxFNs2N49s","role":"tool"}],"model":"anthropic/claude-haiku-4.5","max_tokens":4000,"tool_choice":"auto","tools":[{"function":{"name":"weather","strict":false,"description":"Get weather information for a location","parameters":{"properties":{"location":{"description":"the city","type":"string"}},"required":["location"],"type":"object"}},"type":"function"}],"usage":{"include":true}}' + body: '{"messages":[{"content":"You are a helpful assistant","role":"system","cache_control":{"type":"ephemeral"}},{"content":"What''s the weather in Florence,Italy?","role":"user"},{"tool_calls":[{"id":"toolu_bdrk_0156sNzvRkpm2xuxFNs2N49s","function":{"arguments":"{\"location\": \"Florence, Italy\"}","name":"weather"},"type":"function","cache_control":{"type":"ephemeral"}}],"role":"assistant"},{"content":"40 C","tool_call_id":"toolu_bdrk_0156sNzvRkpm2xuxFNs2N49s","role":"tool","cache_control":{"type":"ephemeral"}}],"model":"anthropic/claude-haiku-4.5","max_tokens":4000,"tool_choice":"auto","tools":[{"function":{"name":"weather","strict":false,"description":"Get weather information for a location","parameters":{"properties":{"location":{"description":"the city","type":"string"}},"required":["location"],"type":"object"}},"type":"function"}],"usage":{"include":true}}' headers: Accept: - application/json diff --git a/providertests/testdata/TestOpenRouterCommonWithAnthropicCache/anthropic/tool_streaming.yaml b/providertests/testdata/TestOpenRouterCommonWithAnthropicCache/anthropic/tool_streaming.yaml index ea83d71cd..c6d027e40 100644 --- a/providertests/testdata/TestOpenRouterCommonWithAnthropicCache/anthropic/tool_streaming.yaml +++ b/providertests/testdata/TestOpenRouterCommonWithAnthropicCache/anthropic/tool_streaming.yaml @@ -65,9 +65,9 @@ interactions: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 - content_length: 886 + content_length: 923 host: "" - body: '{"messages":[{"content":"You are a helpful assistant","role":"system","cache_control":{"type":"ephemeral"}},{"content":"What''s the weather in Florence,Italy?","role":"user"},{"tool_calls":[{"id":"toolu_bdrk_01LTELMMWKNzJUfqvcC4sycZ","function":{"arguments":"{\"location\": \"Florence, Italy\"}","name":"weather"},"type":"function","cache_control":{"type":"ephemeral"}}],"role":"assistant"},{"content":"40 C","tool_call_id":"toolu_bdrk_01LTELMMWKNzJUfqvcC4sycZ","role":"tool"}],"model":"anthropic/claude-haiku-4.5","max_tokens":4000,"stream_options":{"include_usage":true},"tool_choice":"auto","tools":[{"function":{"name":"weather","strict":false,"description":"Get weather information for a location","parameters":{"properties":{"location":{"description":"the city","type":"string"}},"required":["location"],"type":"object"}},"type":"function"}],"usage":{"include":true},"stream":true}' + body: '{"messages":[{"content":"You are a helpful assistant","role":"system","cache_control":{"type":"ephemeral"}},{"content":"What''s the weather in Florence,Italy?","role":"user"},{"tool_calls":[{"id":"toolu_bdrk_01LTELMMWKNzJUfqvcC4sycZ","function":{"arguments":"{\"location\": \"Florence, Italy\"}","name":"weather"},"type":"function","cache_control":{"type":"ephemeral"}}],"role":"assistant"},{"content":"40 C","tool_call_id":"toolu_bdrk_01LTELMMWKNzJUfqvcC4sycZ","role":"tool","cache_control":{"type":"ephemeral"}}],"model":"anthropic/claude-haiku-4.5","max_tokens":4000,"stream_options":{"include_usage":true},"tool_choice":"auto","tools":[{"function":{"name":"weather","strict":false,"description":"Get weather information for a location","parameters":{"properties":{"location":{"description":"the city","type":"string"}},"required":["location"],"type":"object"}},"type":"function"}],"usage":{"include":true},"stream":true}' headers: Accept: - application/json diff --git a/providertests/testdata/TestVercelCommonWithAnthropicCache/claude-sonnet-4/multi_tool.yaml b/providertests/testdata/TestVercelCommonWithAnthropicCache/claude-sonnet-4/multi_tool.yaml index ec320000b..48290c6aa 100644 --- a/providertests/testdata/TestVercelCommonWithAnthropicCache/claude-sonnet-4/multi_tool.yaml +++ b/providertests/testdata/TestVercelCommonWithAnthropicCache/claude-sonnet-4/multi_tool.yaml @@ -36,9 +36,9 @@ interactions: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 - content_length: 1333 + content_length: 1370 host: "" - body: '{"messages":[{"content":"You are a helpful assistant. CRITICAL: Always use both add and multiply at the same time ALWAYS.","role":"system","cache_control":{"type":"ephemeral"}},{"content":"Add and multiply the number 2 and 3","role":"user"},{"tool_calls":[{"id":"toolu_017xBrnU2wm7B5qJHXAWLWTZ","function":{"arguments":"{\"a\":2,\"b\":3}","name":"add"},"type":"function"},{"id":"toolu_01Ctibf2hfhsW1QEmoPx8RtM","function":{"arguments":"{\"a\":2,\"b\":3}","name":"multiply"},"type":"function","cache_control":{"type":"ephemeral"}}],"role":"assistant"},{"content":"5","tool_call_id":"toolu_017xBrnU2wm7B5qJHXAWLWTZ","role":"tool"},{"content":"6","tool_call_id":"toolu_01Ctibf2hfhsW1QEmoPx8RtM","role":"tool"}],"model":"anthropic/claude-sonnet-4","max_tokens":4000,"tool_choice":"auto","tools":[{"function":{"name":"add","strict":false,"description":"Add two numbers","parameters":{"properties":{"a":{"description":"first number","type":"integer"},"b":{"description":"second number","type":"integer"}},"required":["a","b"],"type":"object"}},"type":"function"},{"function":{"name":"multiply","strict":false,"description":"Multiply two numbers","parameters":{"properties":{"a":{"description":"first number","type":"integer"},"b":{"description":"second number","type":"integer"}},"required":["a","b"],"type":"object"}},"type":"function"}]}' + body: '{"messages":[{"content":"You are a helpful assistant. CRITICAL: Always use both add and multiply at the same time ALWAYS.","role":"system","cache_control":{"type":"ephemeral"}},{"content":"Add and multiply the number 2 and 3","role":"user"},{"tool_calls":[{"id":"toolu_017xBrnU2wm7B5qJHXAWLWTZ","function":{"arguments":"{\"a\":2,\"b\":3}","name":"add"},"type":"function"},{"id":"toolu_01Ctibf2hfhsW1QEmoPx8RtM","function":{"arguments":"{\"a\":2,\"b\":3}","name":"multiply"},"type":"function","cache_control":{"type":"ephemeral"}}],"role":"assistant"},{"content":"5","tool_call_id":"toolu_017xBrnU2wm7B5qJHXAWLWTZ","role":"tool"},{"content":"6","tool_call_id":"toolu_01Ctibf2hfhsW1QEmoPx8RtM","role":"tool","cache_control":{"type":"ephemeral"}}],"model":"anthropic/claude-sonnet-4","max_tokens":4000,"tool_choice":"auto","tools":[{"function":{"name":"add","strict":false,"description":"Add two numbers","parameters":{"properties":{"a":{"description":"first number","type":"integer"},"b":{"description":"second number","type":"integer"}},"required":["a","b"],"type":"object"}},"type":"function"},{"function":{"name":"multiply","strict":false,"description":"Multiply two numbers","parameters":{"properties":{"a":{"description":"first number","type":"integer"},"b":{"description":"second number","type":"integer"}},"required":["a","b"],"type":"object"}},"type":"function"}]}' headers: Accept: - application/json diff --git a/providertests/testdata/TestVercelCommonWithAnthropicCache/claude-sonnet-4/multi_tool_streaming.yaml b/providertests/testdata/TestVercelCommonWithAnthropicCache/claude-sonnet-4/multi_tool_streaming.yaml index 85d794596..520241d24 100644 --- a/providertests/testdata/TestVercelCommonWithAnthropicCache/claude-sonnet-4/multi_tool_streaming.yaml +++ b/providertests/testdata/TestVercelCommonWithAnthropicCache/claude-sonnet-4/multi_tool_streaming.yaml @@ -61,9 +61,9 @@ interactions: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 - content_length: 1439 + content_length: 1476 host: "" - body: '{"messages":[{"content":"You are a helpful assistant. Always use both add and multiply at the same time.","role":"system","cache_control":{"type":"ephemeral"}},{"content":"Add and multiply the number 2 and 3","role":"user"},{"content":"I''ll add and multiply the numbers 2 and 3 for you.","tool_calls":[{"id":"toolu_018nsjoYvpEgDt9YVftLaq7w","function":{"arguments":"{\"a\": 2, \"b\": 3}","name":"add"},"type":"function"},{"id":"toolu_011V64RqpHWpxKAL2Wn728hN","function":{"arguments":"{\"a\": 2, \"b\": 3}","name":"multiply"},"type":"function","cache_control":{"type":"ephemeral"}}],"role":"assistant"},{"content":"5","tool_call_id":"toolu_018nsjoYvpEgDt9YVftLaq7w","role":"tool"},{"content":"6","tool_call_id":"toolu_011V64RqpHWpxKAL2Wn728hN","role":"tool"}],"model":"anthropic/claude-sonnet-4","max_tokens":4000,"stream_options":{"include_usage":true},"tool_choice":"auto","tools":[{"function":{"name":"add","strict":false,"description":"Add two numbers","parameters":{"properties":{"a":{"description":"first number","type":"integer"},"b":{"description":"second number","type":"integer"}},"required":["a","b"],"type":"object"}},"type":"function"},{"function":{"name":"multiply","strict":false,"description":"Multiply two numbers","parameters":{"properties":{"a":{"description":"first number","type":"integer"},"b":{"description":"second number","type":"integer"}},"required":["a","b"],"type":"object"}},"type":"function"}],"stream":true}' + body: '{"messages":[{"content":"You are a helpful assistant. Always use both add and multiply at the same time.","role":"system","cache_control":{"type":"ephemeral"}},{"content":"Add and multiply the number 2 and 3","role":"user"},{"content":"I''ll add and multiply the numbers 2 and 3 for you.","tool_calls":[{"id":"toolu_018nsjoYvpEgDt9YVftLaq7w","function":{"arguments":"{\"a\": 2, \"b\": 3}","name":"add"},"type":"function"},{"id":"toolu_011V64RqpHWpxKAL2Wn728hN","function":{"arguments":"{\"a\": 2, \"b\": 3}","name":"multiply"},"type":"function","cache_control":{"type":"ephemeral"}}],"role":"assistant"},{"content":"5","tool_call_id":"toolu_018nsjoYvpEgDt9YVftLaq7w","role":"tool"},{"content":"6","tool_call_id":"toolu_011V64RqpHWpxKAL2Wn728hN","role":"tool","cache_control":{"type":"ephemeral"}}],"model":"anthropic/claude-sonnet-4","max_tokens":4000,"stream_options":{"include_usage":true},"tool_choice":"auto","tools":[{"function":{"name":"add","strict":false,"description":"Add two numbers","parameters":{"properties":{"a":{"description":"first number","type":"integer"},"b":{"description":"second number","type":"integer"}},"required":["a","b"],"type":"object"}},"type":"function"},{"function":{"name":"multiply","strict":false,"description":"Multiply two numbers","parameters":{"properties":{"a":{"description":"first number","type":"integer"},"b":{"description":"second number","type":"integer"}},"required":["a","b"],"type":"object"}},"type":"function"}],"stream":true}' headers: Accept: - application/json diff --git a/providertests/testdata/TestVercelCommonWithAnthropicCache/claude-sonnet-4/tool.yaml b/providertests/testdata/TestVercelCommonWithAnthropicCache/claude-sonnet-4/tool.yaml index 107408f7e..40e71fa10 100644 --- a/providertests/testdata/TestVercelCommonWithAnthropicCache/claude-sonnet-4/tool.yaml +++ b/providertests/testdata/TestVercelCommonWithAnthropicCache/claude-sonnet-4/tool.yaml @@ -36,9 +36,9 @@ interactions: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 - content_length: 794 + content_length: 831 host: "" - body: '{"messages":[{"content":"You are a helpful assistant","role":"system","cache_control":{"type":"ephemeral"}},{"content":"What''s the weather in Florence,Italy?","role":"user"},{"tool_calls":[{"id":"toolu_01HKZKf11CsN9crwBqrsn3yX","function":{"arguments":"{\"location\":\"Florence,Italy\"}","name":"weather"},"type":"function","cache_control":{"type":"ephemeral"}}],"role":"assistant"},{"content":"40 C","tool_call_id":"toolu_01HKZKf11CsN9crwBqrsn3yX","role":"tool"}],"model":"anthropic/claude-sonnet-4","max_tokens":4000,"tool_choice":"auto","tools":[{"function":{"name":"weather","strict":false,"description":"Get weather information for a location","parameters":{"properties":{"location":{"description":"the city","type":"string"}},"required":["location"],"type":"object"}},"type":"function"}]}' + body: '{"messages":[{"content":"You are a helpful assistant","role":"system","cache_control":{"type":"ephemeral"}},{"content":"What''s the weather in Florence,Italy?","role":"user"},{"tool_calls":[{"id":"toolu_01HKZKf11CsN9crwBqrsn3yX","function":{"arguments":"{\"location\":\"Florence,Italy\"}","name":"weather"},"type":"function","cache_control":{"type":"ephemeral"}}],"role":"assistant"},{"content":"40 C","tool_call_id":"toolu_01HKZKf11CsN9crwBqrsn3yX","role":"tool","cache_control":{"type":"ephemeral"}}],"model":"anthropic/claude-sonnet-4","max_tokens":4000,"tool_choice":"auto","tools":[{"function":{"name":"weather","strict":false,"description":"Get weather information for a location","parameters":{"properties":{"location":{"description":"the city","type":"string"}},"required":["location"],"type":"object"}},"type":"function"}]}' headers: Accept: - application/json diff --git a/providertests/testdata/TestVercelCommonWithAnthropicCache/claude-sonnet-4/tool_streaming.yaml b/providertests/testdata/TestVercelCommonWithAnthropicCache/claude-sonnet-4/tool_streaming.yaml index e3f7f8bf3..83ad8efa5 100644 --- a/providertests/testdata/TestVercelCommonWithAnthropicCache/claude-sonnet-4/tool_streaming.yaml +++ b/providertests/testdata/TestVercelCommonWithAnthropicCache/claude-sonnet-4/tool_streaming.yaml @@ -57,9 +57,9 @@ interactions: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 - content_length: 912 + content_length: 949 host: "" - body: '{"messages":[{"content":"You are a helpful assistant","role":"system","cache_control":{"type":"ephemeral"}},{"content":"What''s the weather in Florence,Italy?","role":"user"},{"content":"I''ll check the weather in Florence, Italy for you.","tool_calls":[{"id":"toolu_01M5YDg4x4mT5NJ8zPJB7y3S","function":{"arguments":"{\"location\": \"Florence,Italy\"}","name":"weather"},"type":"function","cache_control":{"type":"ephemeral"}}],"role":"assistant"},{"content":"40 C","tool_call_id":"toolu_01M5YDg4x4mT5NJ8zPJB7y3S","role":"tool"}],"model":"anthropic/claude-sonnet-4","max_tokens":4000,"stream_options":{"include_usage":true},"tool_choice":"auto","tools":[{"function":{"name":"weather","strict":false,"description":"Get weather information for a location","parameters":{"properties":{"location":{"description":"the city","type":"string"}},"required":["location"],"type":"object"}},"type":"function"}],"stream":true}' + body: '{"messages":[{"content":"You are a helpful assistant","role":"system","cache_control":{"type":"ephemeral"}},{"content":"What''s the weather in Florence,Italy?","role":"user"},{"content":"I''ll check the weather in Florence, Italy for you.","tool_calls":[{"id":"toolu_01M5YDg4x4mT5NJ8zPJB7y3S","function":{"arguments":"{\"location\": \"Florence,Italy\"}","name":"weather"},"type":"function","cache_control":{"type":"ephemeral"}}],"role":"assistant"},{"content":"40 C","tool_call_id":"toolu_01M5YDg4x4mT5NJ8zPJB7y3S","role":"tool","cache_control":{"type":"ephemeral"}}],"model":"anthropic/claude-sonnet-4","max_tokens":4000,"stream_options":{"include_usage":true},"tool_choice":"auto","tools":[{"function":{"name":"weather","strict":false,"description":"Get weather information for a location","parameters":{"properties":{"location":{"description":"the city","type":"string"}},"required":["location"],"type":"object"}},"type":"function"}],"stream":true}' headers: Accept: - application/json diff --git a/retry.go b/retry.go index 92e2e0d41..3d44f6b96 100644 --- a/retry.go +++ b/retry.go @@ -17,8 +17,8 @@ type RetryFunction[T any] func(ctx context.Context, fn RetryFn[T]) (T, error) // getRetryDelayInMs calculates the retry delay based on error headers and exponential backoff. func getRetryDelayInMs(err error, exponentialBackoffDelay time.Duration) time.Duration { - var providerErr *ProviderError - if !errors.As(err, &providerErr) || providerErr.ResponseHeaders == nil { + providerErr, ok := errors.AsType[*ProviderError](err) + if !ok || providerErr.ResponseHeaders == nil { return exponentialBackoffDelay } @@ -28,14 +28,14 @@ func getRetryDelayInMs(err error, exponentialBackoffDelay time.Duration) time.Du // retry-ms is more precise than retry-after and used by e.g. OpenAI if retryAfterMs, exists := headers["retry-after-ms"]; exists { if timeoutMs, err := strconv.ParseFloat(retryAfterMs, 64); err == nil { - ms = time.Duration(timeoutMs) * time.Millisecond + ms = time.Duration(timeoutMs * float64(time.Millisecond)) } } // About the Retry-After header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After if retryAfter, exists := headers["retry-after"]; exists && ms == 0 { if timeoutSeconds, err := strconv.ParseFloat(retryAfter, 64); err == nil { - ms = time.Duration(timeoutSeconds) * time.Second + ms = time.Duration(timeoutSeconds * float64(time.Second)) } else { // Try parsing as HTTP date if t, err := time.Parse(time.RFC1123, retryAfter); err == nil { @@ -68,8 +68,8 @@ func RetryWithExponentialBackoffRespectingRetryHeaders[T any](options RetryOptio if err == nil || options.OnAuthRefresh == nil { return result, err } - var authErr *ProviderError - if !errors.As(err, &authErr) || !isAuthError(authErr) { + authErr, ok := errors.AsType[*ProviderError](err) + if !ok || !isAuthError(authErr) { return result, err } if refreshErr := options.OnAuthRefresh(ctx, authErr); refreshErr != nil { @@ -145,7 +145,7 @@ func retryWithExponentialBackoff[T any](ctx context.Context, fn RetryFn[T], opti if isRetryableError(err) && tryNumber <= options.MaxRetries { delay := getRetryDelayInMs(err, options.InitialDelayIn) if options.OnRetry != nil { - errors.As(err, &providerErr) + providerErr, _ = errors.AsType[*ProviderError](err) options.OnRetry(providerErr, delay) } @@ -181,15 +181,13 @@ func isAuthError(err *ProviderError) bool { // level transport errors. The latter two categories may not be wrapped // in ProviderError when they occur outside the provider's error handler. func isRetryableError(err error) bool { - var providerErr *ProviderError - if errors.As(err, &providerErr) { + if providerErr, ok := errors.AsType[*ProviderError](err); ok { return providerErr.IsRetryable() } if isAbortError(err) { return false } - var netErr net.Error - if errors.As(err, &netErr) { + if _, ok := errors.AsType[net.Error](err); ok { return true } return IsTransportError(err) diff --git a/retry_test.go b/retry_test.go index d2a58326b..cfd105fe6 100644 --- a/retry_test.go +++ b/retry_test.go @@ -165,8 +165,7 @@ func TestRetryWithExponentialBackoff_ConnectionErrors(t *testing.T) { return 0, &net.OpError{Op: "dial", Net: "tcp", Err: errors.New("connection refused")} }) - var retryErr *RetryError - if !errors.As(err, &retryErr) { + if _, ok := errors.AsType[*RetryError](err); !ok { t.Fatalf("expected RetryError, got %T: %v", err, err) } }) @@ -298,8 +297,8 @@ func TestRetryWithAuthRefresh(t *testing.T) { return 0, authErr() }) - var providerErr *ProviderError - if !errors.As(err, &providerErr) || providerErr.StatusCode != 401 { + providerErr, ok := errors.AsType[*ProviderError](err) + if !ok || providerErr.StatusCode != 401 { t.Fatalf("expected original 401 ProviderError, got %T: %v", err, err) } if attempts != 1 { @@ -412,3 +411,65 @@ func TestIsAuthError(t *testing.T) { t.Error("expected an AuthError-flagged error to be an auth error") } } + +func TestGetRetryDelayInMs(t *testing.T) { + t.Parallel() + const backoff = 5 * time.Second + for _, tt := range []struct { + name string + headers map[string]string + want time.Duration + }{ + {"fractional seconds", map[string]string{"retry-after": "1.5"}, 1500 * time.Millisecond}, + {"subsecond delay", map[string]string{"retry-after": "0.5"}, 500 * time.Millisecond}, + {"fractional milliseconds", map[string]string{"retry-after-ms": "1.5"}, 1500 * time.Microsecond}, + {"submillisecond delay", map[string]string{"retry-after-ms": "0.5"}, 500 * time.Microsecond}, + {"milliseconds take precedence", map[string]string{"retry-after-ms": "0.5", "retry-after": "2"}, 500 * time.Microsecond}, + {"integer seconds", map[string]string{"retry-after": "2"}, 2 * time.Second}, + {"integer milliseconds", map[string]string{"retry-after-ms": "250"}, 250 * time.Millisecond}, + {"invalid milliseconds fall back to seconds", map[string]string{"retry-after-ms": "invalid", "retry-after": "1.5"}, 1500 * time.Millisecond}, + {"missing headers", nil, backoff}, + {"invalid delay", map[string]string{"retry-after": "invalid"}, backoff}, + {"zero delay", map[string]string{"retry-after": "0"}, backoff}, + {"negative delay", map[string]string{"retry-after": "-1"}, backoff}, + {"excessive delay", map[string]string{"retry-after": "60"}, backoff}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := &ProviderError{StatusCode: 429, ResponseHeaders: tt.headers} + if got := getRetryDelayInMs(err, backoff); got != tt.want { + t.Errorf("getRetryDelayInMs() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestRetryWithExponentialBackoff_FractionalRetryHeader(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var delay time.Duration + attempts := 0 + retry := RetryWithExponentialBackoffRespectingRetryHeaders[int](RetryOptions{ + MaxRetries: 1, + InitialDelayIn: 5 * time.Second, + BackoffFactor: 2, + OnRetry: func(_ *ProviderError, d time.Duration) { + delay = d + cancel() // Observe the selected delay without sleeping or making another request. + }, + }) + _, err := retry(ctx, func() (int, error) { + attempts++ + return 0, &ProviderError{StatusCode: 429, ResponseHeaders: map[string]string{"retry-after": "1.5"}} + }) + if delay != 1500*time.Millisecond { + t.Errorf("retry delay = %v, want 1.5s", delay) + } + if !errors.Is(err, context.Canceled) { + t.Errorf("error = %v, want context.Canceled", err) + } + if attempts != 1 { + t.Errorf("attempts = %d, want 1", attempts) + } +} diff --git a/tool.go b/tool.go index b856d55fa..e2fb57ec4 100644 --- a/tool.go +++ b/tool.go @@ -29,6 +29,11 @@ type ToolCall struct { } // ToolResponse represents the response from a tool execution, matching the existing pattern. +// +// A failure, whether a returned error or IsError, reaches the model as an +// error result and does not end the run. Returning an error discards the +// response, so a tool with a message for the model returns +// NewTextErrorResponse with a nil error. type ToolResponse struct { Type string `json:"type"` Content string `json:"content"` @@ -37,8 +42,10 @@ type ToolResponse struct { // MediaType specifies the MIME type of the media (e.g., "image/png", "audio/wav"). MediaType string `json:"media_type,omitempty"` Metadata string `json:"metadata,omitempty"` - IsError bool `json:"is_error"` - StopTurn bool `json:"stop_turn,omitempty"` + // IsError marks the response as a failure and is non fatal + IsError bool `json:"is_error"` + // StopTurn ends the agent loop after this toolcall and is fatal + StopTurn bool `json:"stop_turn,omitempty"` } // NewTextResponse creates a text response. @@ -49,7 +56,8 @@ func NewTextResponse(content string) ToolResponse { } } -// NewTextErrorResponse creates an error response. +// NewTextErrorResponse creates an error response. The model sees the content +// and gets to try again. func NewTextErrorResponse(content string) ToolResponse { return ToolResponse{ Type: "text", diff --git a/version.txt b/version.txt index 9b0025a78..d5a51828f 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.40.0 +0.45.5