feat(chatd): add provider-native web search tools to chats (#22909)

## What

Adds provider-native web search tools to the chat system. Anthropic,
OpenAI, and Google all offer server-side web search — this wires them up
as opt-in per-model config options using the existing
`ChatModelProviderOptions` JSONB column (no migration).

Web search is **off by default**.

## Config

Set `web_search_enabled: true` in the model config provider options:

```json
{
  "provider_options": {
    "anthropic": {
      "web_search_enabled": true,
      "allowed_domains": ["docs.coder.com", "github.com"]
    }
  }
}
```

Available options per provider:

- **Anthropic**: `web_search_enabled`, `allowed_domains`,
`blocked_domains`
- **OpenAI**: `web_search_enabled`, `search_context_size`
(`low`/`medium`/`high`), `allowed_domains`
- **Google**: `web_search_enabled`

## Backend

- `codersdk/chats.go` — new fields on the per-provider option structs
- `coderd/chatd/chatd.go` — `buildProviderTools()` reads config, creates
`ProviderDefinedTool` entries (uses `anthropic.WebSearchTool()` helper
from fantasy)
- `coderd/chatd/chatloop/chatloop.go` — `ProviderTools` on `RunOptions`,
merged into `Call.Tools`. Provider-executed tool calls skip local
execution. `StreamPartTypeToolResult` with `ProviderExecuted: true` is
accumulated inline (matching fantasy's own agent.go pattern) instead of
post-stream synthesis.
- `coderd/chatd/chatprompt/` — `MarshalToolResult` carries
`ProviderMetadata` through DB persistence so multi-turn round-trips work
(Anthropic needs `encrypted_content` back)

## Frontend

- Source citations render **inline** at the tool-call position (not
bottom-of-message), using `ToolCollapsible` so they look like other tool
cards — collapsed "Searched N results" with globe icon, expand to see
source pills
- Provider-executed tool calls/results are hidden from the normal tool
card UI
- Tool-role messages with only provider-executed results return `null`
(no empty bubble)
- Both persisted (messageParsing.ts) and streaming (streamState.ts)
paths group consecutive `source` parts into a single `{ type: "sources"
}` render block

## Fantasy changes

The fantasy fork (`kylecarbs/fantasy` branch `cj/go1.25`) has the
Anthropic tool code merged in, but will hopefully go upstream from:
https://github.com/charmbracelet/fantasy/pull/163
This commit is contained in:
Kyle Carberry
2026-03-11 21:33:15 +00:00
committed by GitHub
parent fc607cd400
commit 57dc23f603
19 changed files with 606 additions and 143 deletions
+51 -2
View File
@@ -12,6 +12,7 @@ import (
"time"
"charm.land/fantasy"
"charm.land/fantasy/providers/anthropic"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
"golang.org/x/xerrors"
@@ -2124,7 +2125,7 @@ func (p *Server) runChat(
p.maybeGenerateChatTitle(context.WithoutCancel(ctx), chat, messages, model, providerKeys, logger)
}()
prompt, err := chatprompt.ConvertMessagesWithFiles(ctx, messages, p.chatFileResolver())
prompt, err := chatprompt.ConvertMessagesWithFiles(ctx, messages, p.chatFileResolver(), logger)
if err != nil {
return xerrors.Errorf("build chat prompt: %w", err)
}
@@ -2492,6 +2493,13 @@ func (p *Server) runChat(
})...)
}
// Build provider-native tools (e.g., web search) based on
// the model configuration.
var providerTools []fantasy.Tool
if callConfig.ProviderOptions != nil {
providerTools = buildProviderTools(model.Provider(), callConfig.ProviderOptions)
}
err = chatloop.Run(ctx, chatloop.RunOptions{
Model: model,
Messages: prompt,
@@ -2500,6 +2508,7 @@ func (p *Server) runChat(
ModelConfig: callConfig,
ProviderOptions: chatprovider.ProviderOptionsFromChatModelConfig(model, callConfig.ProviderOptions),
ProviderTools: providerTools,
ContextLimitFallback: modelConfigContextLimit,
@@ -2516,7 +2525,7 @@ func (p *Server) runChat(
if err != nil {
return nil, xerrors.Errorf("reload chat messages: %w", err)
}
reloadedPrompt, err := chatprompt.ConvertMessagesWithFiles(reloadCtx, reloadedMsgs, p.chatFileResolver())
reloadedPrompt, err := chatprompt.ConvertMessagesWithFiles(reloadCtx, reloadedMsgs, p.chatFileResolver(), logger)
if err != nil {
return nil, xerrors.Errorf("convert reloaded messages: %w", err)
}
@@ -2564,6 +2573,44 @@ func (p *Server) runChat(
return err
}
// buildProviderTools creates provider-native tool definitions
// (like web search) based on the model configuration. These
// tools are executed server-side by the LLM provider.
func buildProviderTools(_ string, options *codersdk.ChatModelProviderOptions) []fantasy.Tool {
var tools []fantasy.Tool
if options.Anthropic != nil && options.Anthropic.WebSearchEnabled != nil && *options.Anthropic.WebSearchEnabled {
tools = append(tools, anthropic.WebSearchTool(&anthropic.WebSearchToolOptions{
AllowedDomains: options.Anthropic.AllowedDomains,
BlockedDomains: options.Anthropic.BlockedDomains,
}))
}
if options.OpenAI != nil && options.OpenAI.WebSearchEnabled != nil && *options.OpenAI.WebSearchEnabled {
args := map[string]any{}
if options.OpenAI.SearchContextSize != nil && *options.OpenAI.SearchContextSize != "" {
args["search_context_size"] = *options.OpenAI.SearchContextSize
}
if len(options.OpenAI.AllowedDomains) > 0 {
args["allowed_domains"] = options.OpenAI.AllowedDomains
}
tools = append(tools, fantasy.ProviderDefinedTool{
ID: "web_search",
Name: "web_search",
Args: args,
})
}
if options.Google != nil && options.Google.WebSearchEnabled != nil && *options.Google.WebSearchEnabled {
tools = append(tools, fantasy.ProviderDefinedTool{
ID: "web_search",
Name: "web_search",
})
}
return tools
}
// persistChatContextSummary persists a chat context summary to the database.
// This is invoked via the chat loop's compaction callback.
func (p *Server) persistChatContextSummary(
@@ -2618,6 +2665,8 @@ func (p *Server) persistChatContextSummary(
"chat_summarized",
summaryResult,
false,
false,
nil,
)
if err != nil {
return xerrors.Errorf("encode summary tool result: %w", err)
+69 -19
View File
@@ -63,6 +63,12 @@ type RunOptions struct {
// of the provider, which lives in chatd, not chatloop.
ProviderOptions fantasy.ProviderOptions
// ProviderTools are provider-native tools (like web search)
// that are passed directly to the provider API alongside
// function tool definitions. These are not necessarily
// executed server-side; handling is provider-specific.
ProviderTools []fantasy.Tool
PersistStep func(context.Context, PersistedStep) error
PublishMessagePart func(
role fantasy.MessageRole,
@@ -153,9 +159,10 @@ func (r stepResult) toResponseMessages() []fantasy.Message {
continue
}
toolParts = append(toolParts, fantasy.ToolResultPart{
ToolCallID: result.ToolCallID,
Output: result.Result,
ProviderOptions: fantasy.ProviderOptions(result.ProviderMetadata),
ToolCallID: result.ToolCallID,
Output: result.Result,
ProviderExecuted: result.ProviderExecuted,
ProviderOptions: fantasy.ProviderOptions(result.ProviderMetadata),
})
default:
continue
@@ -205,7 +212,7 @@ func Run(ctx context.Context, opts RunOptions) error {
opts.PublishMessagePart(role, part)
}
tools := buildToolDefinitions(opts.Tools, opts.ActiveTools)
tools := buildToolDefinitions(opts.Tools, opts.ActiveTools, opts.ProviderTools)
applyAnthropicCaching := shouldApplyAnthropicPromptCaching(opts.Model)
messages := opts.Messages
@@ -316,7 +323,6 @@ func Run(ctx context.Context, opts RunOptions) error {
Valid: true,
}
}
// Persist the step — errors propagate directly.
if err := opts.PersistStep(ctx, PersistedStep{
Content: result.content,
@@ -494,17 +500,19 @@ func processStepStream(
}
case fantasy.StreamPartTypeToolInputDelta:
var providerExecuted bool
if toolCall, exists := activeToolCalls[part.ID]; exists {
toolCall.Input += part.Delta
providerExecuted = toolCall.ProviderExecuted
}
toolName := toolNames[part.ID]
publishMessagePart(fantasy.MessageRoleAssistant, codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeToolCall,
ToolCallID: part.ID,
ToolName: toolName,
ArgsDelta: part.Delta,
Type: codersdk.ChatMessagePartTypeToolCall,
ToolCallID: part.ID,
ToolName: toolName,
ArgsDelta: part.Delta,
ProviderExecuted: providerExecuted,
})
case fantasy.StreamPartTypeToolInputEnd:
// No callback needed; the full tool call arrives in
// StreamPartTypeToolCall.
@@ -544,6 +552,24 @@ func processStepStream(
chatprompt.PartFromContent(sourceContent),
)
case fantasy.StreamPartTypeToolResult:
// Provider-executed tool results (e.g. web search)
// are emitted by the provider and added directly
// to the step content for multi-turn round-tripping.
// This mirrors fantasy's agent.go accumulation logic.
if part.ProviderExecuted {
tr := fantasy.ToolResultContent{
ToolCallID: part.ID,
ToolName: part.ToolCallName,
ProviderExecuted: part.ProviderExecuted,
ProviderMetadata: part.ProviderMetadata,
}
result.content = append(result.content, tr)
publishMessagePart(
fantasy.MessageRoleTool,
chatprompt.PartFromContent(tr),
)
}
case fantasy.StreamPartTypeFinish:
result.usage = part.Usage
result.finishReason = part.FinishReason
@@ -571,7 +597,14 @@ func processStepStream(
}
}
result.shouldContinue = len(result.toolCalls) > 0 &&
hasLocalToolCalls := false
for _, tc := range result.toolCalls {
if !tc.ProviderExecuted {
hasLocalToolCalls = true
break
}
}
result.shouldContinue = hasLocalToolCalls &&
result.finishReason == fantasy.FinishReasonToolCalls
return result, nil
}
@@ -590,15 +623,29 @@ func executeTools(
return nil
}
// Filter out provider-executed tool calls. These were
// handled server-side by the LLM provider (e.g., web
// search) and their results are already in the stream
// content.
localToolCalls := make([]fantasy.ToolCallContent, 0, len(toolCalls))
for _, tc := range toolCalls {
if !tc.ProviderExecuted {
localToolCalls = append(localToolCalls, tc)
}
}
if len(localToolCalls) == 0 {
return nil
}
toolMap := make(map[string]fantasy.AgentTool, len(allTools))
for _, t := range allTools {
toolMap[t.Info().Name] = t
}
results := make([]fantasy.ToolResultContent, len(toolCalls))
results := make([]fantasy.ToolResultContent, len(localToolCalls))
var wg sync.WaitGroup
wg.Add(len(toolCalls))
for i, tc := range toolCalls {
wg.Add(len(localToolCalls))
for i, tc := range localToolCalls {
go func(i int, tc fantasy.ToolCallContent) {
defer wg.Done()
defer func() {
@@ -770,8 +817,9 @@ func persistInterruptedStep(
continue
}
content = append(content, fantasy.ToolResultContent{
ToolCallID: tc.ToolCallID,
ToolName: tc.ToolName,
ToolCallID: tc.ToolCallID,
ToolName: tc.ToolName,
ProviderExecuted: tc.ProviderExecuted,
Result: fantasy.ToolResultOutputContentError{
Error: xerrors.New(interruptedToolResultErrorMessage),
},
@@ -791,9 +839,10 @@ func persistInterruptedStep(
// buildToolDefinitions converts AgentTool definitions into the
// fantasy.Tool slice expected by fantasy.Call. When activeTools
// is non-empty, only tools whose name appears in the list are
// included. This mirrors fantasy's agent.prepareTools filtering.
func buildToolDefinitions(tools []fantasy.AgentTool, activeTools []string) []fantasy.Tool {
// is non-empty, only function tools whose name appears in the
// list are included. Provider tools bypass this filter and are
// always appended unconditionally.
func buildToolDefinitions(tools []fantasy.AgentTool, activeTools []string, providerTools []fantasy.Tool) []fantasy.Tool {
prepared := make([]fantasy.Tool, 0, len(tools))
for _, tool := range tools {
info := tool.Info()
@@ -813,6 +862,7 @@ func buildToolDefinitions(tools []fantasy.AgentTool, activeTools []string) []fan
ProviderOptions: tool.ProviderOptions(),
})
}
prepared = append(prepared, providerTools...)
return prepared
}
+74 -24
View File
@@ -11,6 +11,7 @@ import (
"github.com/sqlc-dev/pqtype"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/codersdk"
)
@@ -110,7 +111,7 @@ func patchFileContent(
func ConvertMessages(
messages []database.ChatMessage,
) ([]fantasy.Message, error) {
return ConvertMessagesWithFiles(context.Background(), messages, nil)
return ConvertMessagesWithFiles(context.Background(), messages, nil, slog.Logger{})
}
// ConvertMessagesWithFiles converts persisted chat messages into LLM
@@ -121,6 +122,7 @@ func ConvertMessagesWithFiles(
ctx context.Context,
messages []database.ChatMessage,
resolver FileResolver,
logger slog.Logger,
) ([]fantasy.Message, error) {
// Phase 1: Pre-scan user messages for file_id references.
var allFileIDs []uuid.UUID
@@ -229,7 +231,7 @@ func ConvertMessagesWithFiles(
if row.ToolCallID != "" && row.ToolName != "" {
toolNameByCallID[sanitizeToolCallID(row.ToolCallID)] = row.ToolName
}
parts = append(parts, row.toToolResultPart())
parts = append(parts, row.toToolResultPart(logger))
}
prompt = append(prompt, fantasy.Message{
Role: fantasy.MessageRoleTool,
@@ -359,10 +361,12 @@ func ParseContent(role string, raw pqtype.NullRawMessage) ([]fantasy.Content, er
// result row. We intentionally avoid a strict Go struct so that
// historical shapes are never rejected.
type toolResultRaw struct {
ToolCallID string `json:"tool_call_id"`
ToolName string `json:"tool_name"`
Result json.RawMessage `json:"result"`
IsError bool `json:"is_error,omitempty"`
ToolCallID string `json:"tool_call_id"`
ToolName string `json:"tool_name"`
Result json.RawMessage `json:"result"`
IsError bool `json:"is_error,omitempty"`
ProviderExecuted bool `json:"provider_executed,omitempty"`
ProviderMetadata json.RawMessage `json:"provider_metadata,omitempty"`
}
// parseToolResultRows decodes persisted tool result rows.
@@ -378,7 +382,7 @@ func parseToolResultRows(raw pqtype.NullRawMessage) ([]toolResultRaw, error) {
return rows, nil
}
func (r toolResultRaw) toToolResultPart() fantasy.ToolResultPart {
func (r toolResultRaw) toToolResultPart(logger slog.Logger) fantasy.ToolResultPart {
toolCallID := sanitizeToolCallID(r.ToolCallID)
resultText := string(r.Result)
if resultText == "" || resultText == "null" {
@@ -391,7 +395,9 @@ func (r toolResultRaw) toToolResultPart() fantasy.ToolResultPart {
message = extracted
}
return fantasy.ToolResultPart{
ToolCallID: toolCallID,
ToolCallID: toolCallID,
ProviderExecuted: r.ProviderExecuted,
ProviderOptions: r.providerOptions(logger),
Output: fantasy.ToolResultOutputContentError{
Error: xerrors.New(message),
},
@@ -399,13 +405,43 @@ func (r toolResultRaw) toToolResultPart() fantasy.ToolResultPart {
}
return fantasy.ToolResultPart{
ToolCallID: toolCallID,
ToolCallID: toolCallID,
ProviderExecuted: r.ProviderExecuted,
ProviderOptions: r.providerOptions(logger),
Output: fantasy.ToolResultOutputContentText{
Text: resultText,
},
}
}
// providerOptions deserializes the stored provider metadata
// JSON into a ProviderOptions map using the fantasy type
// registry. Returns nil when no metadata is stored.
func (r toolResultRaw) providerOptions(logger slog.Logger) fantasy.ProviderOptions {
if len(r.ProviderMetadata) == 0 {
return nil
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(r.ProviderMetadata, &raw); err != nil {
logger.Warn(context.Background(),
"failed to unmarshal provider metadata JSON",
slog.F("tool_call_id", r.ToolCallID),
slog.Error(err),
)
return nil
}
opts, err := fantasy.UnmarshalProviderOptions(raw)
if err != nil {
logger.Warn(context.Background(),
"failed to deserialize provider metadata",
slog.F("tool_call_id", r.ToolCallID),
slog.Error(err),
)
return nil
}
return opts
}
// extractErrorString pulls the "error" field from a JSON object if
// present, returning it as a string. Returns "" if the field is
// missing or the input is not an object.
@@ -609,12 +645,22 @@ func injectFileID(encoded json.RawMessage, fileID uuid.UUID) (json.RawMessage, e
// MarshalToolResult encodes a single tool result for persistence as
// an opaque JSON blob. The stored shape is
// [{"tool_call_id":…,"tool_name":…,"result":…,"is_error":…}].
func MarshalToolResult(toolCallID, toolName string, result json.RawMessage, isError bool) (pqtype.NullRawMessage, error) {
func MarshalToolResult(toolCallID, toolName string, result json.RawMessage, isError bool, providerExecuted bool, providerMetadata fantasy.ProviderMetadata) (pqtype.NullRawMessage, error) {
var metaJSON json.RawMessage
if len(providerMetadata) > 0 {
var err error
metaJSON, err = json.Marshal(providerMetadata)
if err != nil {
return pqtype.NullRawMessage{}, xerrors.Errorf("encode provider metadata: %w", err)
}
}
row := toolResultRaw{
ToolCallID: toolCallID,
ToolName: toolName,
Result: result,
IsError: isError,
ToolCallID: toolCallID,
ToolName: toolName,
Result: result,
IsError: isError,
ProviderExecuted: providerExecuted,
ProviderMetadata: metaJSON,
}
data, err := json.Marshal([]toolResultRaw{row})
if err != nil {
@@ -653,7 +699,7 @@ func MarshalToolResultContent(content fantasy.ToolResultContent) (pqtype.NullRaw
result = []byte(`{}`)
}
return MarshalToolResult(content.ToolCallID, content.ToolName, result, isError)
return MarshalToolResult(content.ToolCallID, content.ToolName, result, isError, content.ProviderExecuted, content.ProviderMetadata)
}
// PartFromContent converts fantasy content into a SDK chat message part.
@@ -681,17 +727,19 @@ func PartFromContent(block fantasy.Content) codersdk.ChatMessagePart {
}
case fantasy.ToolCallContent:
return codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeToolCall,
ToolCallID: value.ToolCallID,
ToolName: value.ToolName,
Args: []byte(value.Input),
Type: codersdk.ChatMessagePartTypeToolCall,
ToolCallID: value.ToolCallID,
ToolName: value.ToolName,
Args: []byte(value.Input),
ProviderExecuted: value.ProviderExecuted,
}
case *fantasy.ToolCallContent:
return codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeToolCall,
ToolCallID: value.ToolCallID,
ToolName: value.ToolName,
Args: []byte(value.Input),
Type: codersdk.ChatMessagePartTypeToolCall,
ToolCallID: value.ToolCallID,
ToolName: value.ToolName,
Args: []byte(value.Input),
ProviderExecuted: value.ProviderExecuted,
}
case fantasy.SourceContent:
return codersdk.ChatMessagePart{
@@ -771,7 +819,9 @@ func toolResultContentToPart(content fantasy.ToolResultContent) codersdk.ChatMes
result = []byte(`{}`)
}
return ToolResultToPart(content.ToolCallID, content.ToolName, result, isError)
part := ToolResultToPart(content.ToolCallID, content.ToolName, result, isError)
part.ProviderExecuted = content.ProviderExecuted
return part
}
func injectMissingToolResults(prompt []fantasy.Message) []fantasy.Message {
@@ -10,6 +10,7 @@ import (
"github.com/sqlc-dev/pqtype"
"github.com/stretchr/testify/require"
"cdr.dev/slog/v3/sloggers/slogtest"
"github.com/coder/coder/v2/coderd/chatd/chatprompt"
"github.com/coder/coder/v2/coderd/database"
)
@@ -63,6 +64,8 @@ func TestConvertMessages_NormalizesAssistantToolCallInput(t *testing.T) {
"execute",
json.RawMessage(`{"error":"tool call was interrupted before it produced a result"}`),
true,
false,
nil,
)
require.NoError(t, err)
@@ -134,6 +137,7 @@ func TestConvertMessagesWithFiles_ResolvesFileData(t *testing.T) {
},
},
resolver,
slogtest.Make(t, nil),
)
require.NoError(t, err)
require.Len(t, prompt, 1)
@@ -175,6 +179,7 @@ func TestConvertMessagesWithFiles_BackwardCompat(t *testing.T) {
},
},
nil, // No resolver.
slogtest.Make(t, nil),
)
require.NoError(t, err)
require.Len(t, prompt, 1)
+21 -17
View File
@@ -1193,11 +1193,12 @@ func chatMessageParts(role string, raw pqtype.NullRawMessage) ([]codersdk.ChatMe
parts := make([]codersdk.ChatMessagePart, 0, len(results))
for _, result := range results {
parts = append(parts, codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeToolResult,
ToolCallID: result.ToolCallID,
ToolName: result.ToolName,
Result: result.Result,
IsError: result.IsError,
Type: codersdk.ChatMessagePartTypeToolResult,
ToolCallID: result.ToolCallID,
ToolName: result.ToolName,
Result: result.Result,
IsError: result.IsError,
ProviderExecuted: result.ProviderExecuted,
})
}
return parts, nil
@@ -1251,10 +1252,11 @@ func parseContentBlocks(role string, raw pqtype.NullRawMessage) ([]fantasy.Conte
// toolResultRow is used only for extracting top-level fields from
// persisted tool result JSON. The result payload is kept as raw JSON.
type toolResultRow struct {
ToolCallID string `json:"tool_call_id"`
ToolName string `json:"tool_name"`
Result json.RawMessage `json:"result"`
IsError bool `json:"is_error,omitempty"`
ToolCallID string `json:"tool_call_id"`
ToolName string `json:"tool_name"`
Result json.RawMessage `json:"result"`
IsError bool `json:"is_error,omitempty"`
ProviderExecuted bool `json:"provider_executed,omitempty"`
}
func parseToolResults(raw pqtype.NullRawMessage) ([]toolResultRow, error) {
@@ -1293,17 +1295,19 @@ func contentBlockToPart(block fantasy.Content) codersdk.ChatMessagePart {
}
case fantasy.ToolCallContent:
return codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeToolCall,
ToolCallID: value.ToolCallID,
ToolName: value.ToolName,
Args: []byte(value.Input),
Type: codersdk.ChatMessagePartTypeToolCall,
ToolCallID: value.ToolCallID,
ToolName: value.ToolName,
Args: []byte(value.Input),
ProviderExecuted: value.ProviderExecuted,
}
case *fantasy.ToolCallContent:
return codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeToolCall,
ToolCallID: value.ToolCallID,
ToolName: value.ToolName,
Args: []byte(value.Input),
Type: codersdk.ChatMessagePartTypeToolCall,
ToolCallID: value.ToolCallID,
ToolName: value.ToolName,
Args: []byte(value.Input),
ProviderExecuted: value.ProviderExecuted,
}
case fantasy.SourceContent:
return codersdk.ChatMessagePart{
+28 -20
View File
@@ -83,22 +83,23 @@ const (
// ChatMessagePart is a structured chunk of a chat message.
type ChatMessagePart struct {
Type ChatMessagePartType `json:"type"`
Text string `json:"text,omitempty"`
Signature string `json:"signature,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
ToolName string `json:"tool_name,omitempty"`
Args json.RawMessage `json:"args,omitempty"`
ArgsDelta string `json:"args_delta,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
ResultDelta string `json:"result_delta,omitempty"`
IsError bool `json:"is_error,omitempty"`
SourceID string `json:"source_id,omitempty"`
URL string `json:"url,omitempty"`
Title string `json:"title,omitempty"`
MediaType string `json:"media_type,omitempty"`
Data []byte `json:"data,omitempty"`
FileID uuid.NullUUID `json:"file_id,omitempty" format:"uuid"`
Type ChatMessagePartType `json:"type"`
Text string `json:"text,omitempty"`
Signature string `json:"signature,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
ToolName string `json:"tool_name,omitempty"`
Args json.RawMessage `json:"args,omitempty"`
ArgsDelta string `json:"args_delta,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
ResultDelta string `json:"result_delta,omitempty"`
IsError bool `json:"is_error,omitempty"`
ProviderExecuted bool `json:"provider_executed,omitempty"`
SourceID string `json:"source_id,omitempty"`
URL string `json:"url,omitempty"`
Title string `json:"title,omitempty"`
MediaType string `json:"media_type,omitempty"`
Data []byte `json:"data,omitempty"`
FileID uuid.NullUUID `json:"file_id,omitempty" format:"uuid"`
// The following fields are only set when Type is
// ChatInputPartTypeFileReference.
FileName string `json:"file_name,omitempty"`
@@ -313,6 +314,9 @@ type ChatModelOpenAIProviderOptions struct {
ServiceTier *string `json:"service_tier,omitempty" description:"Latency tier to use for processing the request"`
StructuredOutputs *bool `json:"structured_outputs,omitempty" description:"Whether to enable structured JSON output mode" hidden:"true"`
StrictJSONSchema *bool `json:"strict_json_schema,omitempty" description:"Whether to enforce strict adherence to the JSON schema" hidden:"true"`
WebSearchEnabled *bool `json:"web_search_enabled,omitempty" description:"Enable OpenAI web search tool for grounding responses with real-time information"`
SearchContextSize *string `json:"search_context_size,omitempty" description:"Amount of search context to use" enum:"low,medium,high"`
AllowedDomains []string `json:"allowed_domains,omitempty" description:"Restrict web search to these domains"`
}
// ChatModelAnthropicThinkingOptions configures Anthropic thinking budget.
@@ -326,6 +330,9 @@ type ChatModelAnthropicProviderOptions struct {
Thinking *ChatModelAnthropicThinkingOptions `json:"thinking,omitempty" description:"Configuration for extended thinking"`
Effort *string `json:"effort,omitempty" description:"Controls the level of reasoning effort" enum:"low,medium,high,max"`
DisableParallelToolUse *bool `json:"disable_parallel_tool_use,omitempty" description:"Whether to disable parallel tool execution"`
WebSearchEnabled *bool `json:"web_search_enabled,omitempty" description:"Enable Anthropic web search tool for grounding responses with real-time information"`
AllowedDomains []string `json:"allowed_domains,omitempty" description:"Restrict web search to these domains (cannot be used with blocked_domains)"`
BlockedDomains []string `json:"blocked_domains,omitempty" description:"Block web search on these domains (cannot be used with allowed_domains)"`
}
// ChatModelGoogleThinkingConfig configures Google thinking behavior.
@@ -342,10 +349,11 @@ type ChatModelGoogleSafetySetting struct {
// ChatModelGoogleProviderOptions configures Google provider behavior.
type ChatModelGoogleProviderOptions struct {
ThinkingConfig *ChatModelGoogleThinkingConfig `json:"thinking_config,omitempty" description:"Configuration for extended thinking"`
CachedContent string `json:"cached_content,omitempty" description:"Resource name of a cached content object" hidden:"true"`
SafetySettings []ChatModelGoogleSafetySetting `json:"safety_settings,omitempty" description:"Safety filtering settings for harmful content categories" hidden:"true"`
Threshold string `json:"threshold,omitempty" hidden:"true"`
ThinkingConfig *ChatModelGoogleThinkingConfig `json:"thinking_config,omitempty" description:"Configuration for extended thinking"`
CachedContent string `json:"cached_content,omitempty" description:"Resource name of a cached content object" hidden:"true"`
SafetySettings []ChatModelGoogleSafetySetting `json:"safety_settings,omitempty" description:"Safety filtering settings for harmful content categories" hidden:"true"`
Threshold string `json:"threshold,omitempty" hidden:"true"`
WebSearchEnabled *bool `json:"web_search_enabled,omitempty" description:"Enable Google Search grounding for real-time information"`
}
// ChatModelOpenAICompatProviderOptions configures OpenAI-compatible behavior.
+21 -21
View File
@@ -76,7 +76,7 @@ replace github.com/spf13/afero => github.com/aslilac/afero v0.0.0-20250403163713
// 1) Adds thinking effort to Anthropic provider
// 2) Downgraded to Go 1.25 due to issue with Windows CI
// https://github.com/kylecarbs/fantasy/compare/main...kylecarbs:fantasy:cj/go1.25
replace charm.land/fantasy => github.com/kylecarbs/fantasy v0.0.0-20260225152134-45ae0791c21f
replace charm.land/fantasy => github.com/kylecarbs/fantasy v0.0.0-20260311152049-8eff3573caec
replace github.com/charmbracelet/anthropic-sdk-go => github.com/kylecarbs/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab
@@ -91,7 +91,7 @@ require (
github.com/aquasecurity/trivy-iac v0.8.0
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2
github.com/awalterschulze/gographviz v2.0.3+incompatible
github.com/aws/smithy-go v1.24.1
github.com/aws/smithy-go v1.24.2
github.com/bramvdbogaerde/go-scp v1.6.0
github.com/briandowns/spinner v1.23.0
github.com/cakturk/go-netstat v0.0.0-20200220111822-e5b49efee7a5
@@ -214,7 +214,7 @@ require (
golang.org/x/text v0.34.0
golang.org/x/tools v0.42.0
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
google.golang.org/api v0.267.0
google.golang.org/api v0.269.0
google.golang.org/grpc v1.79.1
google.golang.org/protobuf v1.36.11
gopkg.in/DataDog/dd-trace-go.v1 v1.74.0
@@ -260,20 +260,20 @@ require (
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.1
github.com/aws/aws-sdk-go-v2/config v1.32.9
github.com/aws/aws-sdk-go-v2/credentials v1.19.9 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.3
github.com/aws/aws-sdk-go-v2/config v1.32.11
github.com/aws/aws-sdk-go-v2/credentials v1.19.11 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 // indirect
github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.2
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 // indirect
github.com/aws/aws-sdk-go-v2/service/ssm v1.60.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.10 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
@@ -456,7 +456,7 @@ require (
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
gopkg.in/ini.v1 v1.67.1 // indirect
howett.net/plist v1.0.0 // indirect
kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 // indirect
@@ -468,6 +468,7 @@ require github.com/coder/clistat v1.2.1
require github.com/SherClockHolmes/webpush-go v1.4.0
require (
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect
@@ -518,18 +519,17 @@ require (
github.com/aquasecurity/jfather v0.0.8 // indirect
github.com/aquasecurity/trivy v0.61.1-0.20250407075540-f1329c7ea1aa // indirect
github.com/aquasecurity/trivy-checks v1.12.2-0.20251219190323-79d27547baf5 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect
github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 // indirect
github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5 // indirect
github.com/charmbracelet/x/json v0.2.0 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
@@ -611,7 +611,7 @@ require (
go.yaml.in/yaml/v3 v3.0.4 // indirect
go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 // indirect
google.golang.org/genai v1.47.0 // indirect
google.golang.org/genai v1.49.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d // indirect
mvdan.cc/gofumpt v0.8.0 // indirect
+38 -38
View File
@@ -162,48 +162,48 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/awalterschulze/gographviz v2.0.3+incompatible h1:9sVEXJBJLwGX7EQVhLm2elIKCm7P2YHFC8v6096G09E=
github.com/awalterschulze/gographviz v2.0.3+incompatible/go.mod h1:GEV5wmg4YquNw7v1kkyoX9etIk8yVmXj+AkDHuuETHs=
github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
github.com/aws/aws-sdk-go-v2/config v1.32.9 h1:ktda/mtAydeObvJXlHzyGpK1xcsLaP16zfUPDGoW90A=
github.com/aws/aws-sdk-go-v2/config v1.32.9/go.mod h1:U+fCQ+9QKsLW786BCfEjYRj34VVTbPdsLP3CHSYXMOI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.9 h1:sWvTKsyrMlJGEuj/WgrwilpoJ6Xa1+KhIpGdzw7mMU8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.9/go.mod h1:+J44MBhmfVY/lETFiKI+klz0Vym2aCmIjqgClMmW82w=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA=
github.com/aws/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA=
github.com/aws/aws-sdk-go-v2 v1.41.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5/go.mod h1:nVUlMLVV8ycXSb7mSkcNu9e3v/1TJq2RTlrPwhYWr5c=
github.com/aws/aws-sdk-go-v2/config v1.32.11 h1:ftxI5sgz8jZkckuUHXfC/wMUc8u3fG1vQS0plr2F2Zs=
github.com/aws/aws-sdk-go-v2/config v1.32.11/go.mod h1:twF11+6ps9aNRKEDimksp923o44w/Thk9+8YIlzWMmo=
github.com/aws/aws-sdk-go-v2/credentials v1.19.11 h1:NdV8cwCcAXrCWyxArt58BrvZJ9pZ9Fhf9w6Uh5W3Uyc=
github.com/aws/aws-sdk-go-v2/credentials v1.19.11/go.mod h1:30yY2zqkMPdrvxBqzI9xQCM+WrlrZKSOpSJEsylVU+8=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 h1:INUvJxmhdEbVulJYHI061k4TVuS3jzzthNvjqvVvTKM=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19/go.mod h1:FpZN2QISLdEBWkayloda+sZjVJL+e9Gl0k1SyTgcswU=
github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.2 h1:QbFjOdplTkOgviHNKyTW/TZpvIYhD6lqEc3tkIvqMoQ=
github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.2/go.mod h1:d0pTYUeTv5/tPSlbPZZQSqssM158jZBs02jx2LDslM8=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 h1:/sECfyq2JTifMI2JPyZ4bdRN77zJmr6SrS1eL3augIA=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19/go.mod h1:dMf8A5oAqr9/oxOfLkC/c2LU/uMcALP0Rgn2BD5LWn0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 h1:AWeJMk33GTBf6J20XJe6qZoRSJo0WfUhsMdUKhoODXE=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19/go.mod h1:+GWrYoaAsV7/4pNHpwh1kiNLXkKaSoppxQq9lbH8Ejw=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 h1:clHU5fm//kWS1C2HgtgWxfQbFbx4b6rx+5jzhgX9HrI=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 h1:XAq62tBTJP/85lFD5oqOOe7YYgWxY9LvWq8plyDvDVg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 h1:Z5EiPIzXKewUQK0QTMkutjiaPVeVYXX7KIqhXu/0fXs=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8/go.mod h1:FsTpJtvC4U1fyDXk7c71XoDv3HlRm8V3NiYLeYLh5YE=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 h1:X1Tow7suZk9UCJHE1Iw9GMZJJl0dAnKXXP1NaSDHwmw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19/go.mod h1:/rARO8psX+4sfjUQXp5LLifjUt8DuATZ31WptNJTyQA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 h1:bGeHBsGZx0Dvu/eJC0Lh9adJa3M1xREcndxLNZlve2U=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g=
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 h1:oeu8VPlOre74lBA/PMhxa5vewaMIMmILM+RraSyB8KA=
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 h1:Y2cAXlClHsXkkOvWZFXATr34b0hxxloeQu/pAZz2row=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7/go.mod h1:idzZ7gmDeqeNrSPkdbtMp9qWMgcBwykA7P7Rzh5DXVU=
github.com/aws/aws-sdk-go-v2/service/ssm v1.60.1 h1:OwMzNDe5VVTXD4kGmeK/FtqAITiV8Mw4TCa8IyNO0as=
github.com/aws/aws-sdk-go-v2/service/ssm v1.60.1/go.mod h1:IyVabkWrs8SNdOEZLyFFcW9bUltV4G6OQS0s6H20PHg=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.10 h1:+VTRawC4iVY58pS/lzpo0lnoa/SYNGF4/B/3/U5ro8Y=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.10/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 h1:0jbJeuEHlwKJ9PfXtpSFc4MF+WIWORdhN1n30ITZGFM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0=
github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 h1:iSsvB9EtQ09YrsmIc44Heqlx5ByGErqhPK1ZQLppias=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12/go.mod h1:fEWYKTRGoZNl8tZ77i61/ccwOMJdGxwOhWCkp6TXAr0=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 h1:EnUdUqRP1CNzt2DkV67tJx6XDN4xlfBFm+bzeNOQVb0=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16/go.mod h1:Jic/xv0Rq/pFNCh3WwpH4BEqdbSAl+IyHro8LbibHD8=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 h1:XQTQTF75vnug2TXS8m7CVJfC2nniYPZnO1D4Np761Oo=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8/go.mod h1:Xgx+PR1NUOjNmQY+tRMnouRp83JRM8pRMw/vCaVhPkI=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
@@ -797,8 +797,8 @@ github.com/kylecarbs/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab h1:5UMY
github.com/kylecarbs/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab/go.mod h1:hqlYqR7uPKOKfnNeicUbZp0Ps0GeYFlKYtwh5HGDCx8=
github.com/kylecarbs/chroma/v2 v2.0.0-20240401211003-9e036e0631f3 h1:Z9/bo5PSeMutpdiKYNt/TTSfGM1Ll0naj3QzYX9VxTc=
github.com/kylecarbs/chroma/v2 v2.0.0-20240401211003-9e036e0631f3/go.mod h1:BUGjjsD+ndS6eX37YgTchSEG+Jg9Jv1GiZs9sqPqztk=
github.com/kylecarbs/fantasy v0.0.0-20260225152134-45ae0791c21f h1:8Xa42VBnKO+83zEhFBZF2eKVOl1pCLXxeCXOllCSVB4=
github.com/kylecarbs/fantasy v0.0.0-20260225152134-45ae0791c21f/go.mod h1:Ro/xRzPtU/+P178CMzGBn8XsEFp/X/nr7meQ1pjX7eI=
github.com/kylecarbs/fantasy v0.0.0-20260311152049-8eff3573caec h1:JBaAQYkOre8gAaQZ6SKiQZ2g+em35WxzHTWtTtFDX5M=
github.com/kylecarbs/fantasy v0.0.0-20260311152049-8eff3573caec/go.mod h1:p6cYJVG8D8AC51MgejAKCMu0myRyQ+vKLuoJQ3biaXo=
github.com/kylecarbs/spinner v1.18.2-0.20220329160715-20702b5af89e h1:OP0ZMFeZkUnOzTFRfpuK3m7Kp4fNvC6qN+exwj7aI4M=
github.com/kylecarbs/spinner v1.18.2-0.20220329160715-20702b5af89e/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
@@ -1495,19 +1495,19 @@ golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
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.267.0 h1:w+vfWPMPYeRs8qH1aYYsFX68jMls5acWl/jocfLomwE=
google.golang.org/api v0.267.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0=
google.golang.org/api v0.269.0 h1:qDrTOxKUQ/P0MveH6a7vZ+DNHxJQjtGm/uvdbdGXCQg=
google.golang.org/api v0.269.0/go.mod h1:N8Wpcu23Tlccl0zSHEkcAZQKDLdquxK+l9r2LkwAauE=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/genai v1.47.0 h1:iWCS7gEdO6rctOqfCYLOrZGKu2D+N42aTnCEcBvB1jo=
google.golang.org/genai v1.47.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
google.golang.org/genai v1.49.0 h1:Se+QJaH2GYK1aaR1o5S38mlU2GD5FnVvP76nfkV7LH0=
google.golang.org/genai v1.49.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d h1:vsOm753cOAMkt76efriTCDKjpCbK18XGHMJHo0JUKhc=
google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:0oz9d7g9QLSdv9/lgbIjowW1JoxMbxmBVNe8i6tORJI=
google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d h1:EocjzKLywydp5uZ5tJ79iP6Q0UjDnyiHkGRWxuPBP8s=
google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:48U2I+QQUYhsFrg2SY6r+nJzeOtjey7j//WBESw+qyQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
@@ -86,6 +86,30 @@
"description": "Whether to disable parallel tool execution",
"required": false,
"input_type": "select"
},
{
"json_name": "web_search_enabled",
"go_name": "WebSearchEnabled",
"type": "boolean",
"description": "Enable Anthropic web search tool for grounding responses with real-time information",
"required": false,
"input_type": "select"
},
{
"json_name": "allowed_domains",
"go_name": "AllowedDomains",
"type": "array",
"description": "Restrict web search to these domains (cannot be used with blocked_domains)",
"required": false,
"input_type": "json"
},
{
"json_name": "blocked_domains",
"go_name": "BlockedDomains",
"type": "array",
"description": "Block web search on these domains (cannot be used with allowed_domains)",
"required": false,
"input_type": "json"
}
]
},
@@ -132,6 +156,14 @@
"required": false,
"input_type": "input",
"hidden": true
},
{
"json_name": "web_search_enabled",
"go_name": "WebSearchEnabled",
"type": "boolean",
"description": "Enable Google Search grounding for real-time information",
"required": false,
"input_type": "select"
}
]
},
@@ -311,6 +343,31 @@
"required": false,
"input_type": "select",
"hidden": true
},
{
"json_name": "web_search_enabled",
"go_name": "WebSearchEnabled",
"type": "boolean",
"description": "Enable OpenAI web search tool for grounding responses with real-time information",
"required": false,
"input_type": "select"
},
{
"json_name": "search_context_size",
"go_name": "SearchContextSize",
"type": "string",
"description": "Amount of search context to use",
"required": false,
"enum": ["low", "medium", "high"],
"input_type": "select"
},
{
"json_name": "allowed_domains",
"go_name": "AllowedDomains",
"type": "array",
"description": "Restrict web search to these domains",
"required": false,
"input_type": "json"
}
]
},
+8
View File
@@ -1175,6 +1175,7 @@ export interface ChatMessagePart {
readonly result?: Record<string, string>;
readonly result_delta?: string;
readonly is_error?: boolean;
readonly provider_executed?: boolean;
readonly source_id?: string;
readonly url?: string;
readonly title?: string;
@@ -1248,6 +1249,9 @@ export interface ChatModelAnthropicProviderOptions {
readonly thinking?: ChatModelAnthropicThinkingOptions;
readonly effort?: string;
readonly disable_parallel_tool_use?: boolean;
readonly web_search_enabled?: boolean;
readonly allowed_domains?: readonly string[];
readonly blocked_domains?: readonly string[];
}
// From codersdk/chats.go
@@ -1299,6 +1303,7 @@ export interface ChatModelGoogleProviderOptions {
readonly cached_content?: string;
readonly safety_settings?: readonly ChatModelGoogleSafetySetting[];
readonly threshold?: string;
readonly web_search_enabled?: boolean;
}
// From codersdk/chats.go
@@ -1355,6 +1360,9 @@ export interface ChatModelOpenAIProviderOptions {
readonly service_tier?: string;
readonly structured_outputs?: boolean;
readonly strict_json_schema?: boolean;
readonly web_search_enabled?: boolean;
readonly search_context_size?: string;
readonly allowed_domains?: readonly string[];
}
// From codersdk/chats.go
@@ -0,0 +1,111 @@
import { ExternalLinkIcon, GlobeIcon } from "lucide-react";
import { type FC, useMemo } from "react";
import { cn } from "utils/cn";
import { ToolCollapsible } from "./ToolCollapsible";
interface WebSearchSourcesProps {
sources: Array<{ url: string; title: string }>;
}
/**
* Renders web search sources as a collapsible tool card, consistent
* with other tool call renderings. The collapsed header shows a globe
* icon and "Searched N sources"; expanding reveals clickable pills.
*/
const WebSearchSources: FC<WebSearchSourcesProps> = ({ sources }) => {
// Deduplicate sources by URL, keeping the first occurrence.
const unique = useMemo(() => {
const seen = new Set<string>();
return sources.filter((s) => {
if (!s.url || seen.has(s.url)) {
return false;
}
seen.add(s.url);
return true;
});
}, [sources]);
if (unique.length === 0) {
return null;
}
const detail = unique.length === 1 ? "1 result" : `${unique.length} results`;
return (
<ToolCollapsible
hasContent={unique.length > 0}
header={
<>
<GlobeIcon className="h-4 w-4 shrink-0 text-content-secondary" />
<span className="text-sm text-content-secondary">
Searched <span className="text-content-secondary/60">{detail}</span>
</span>
</>
}
>
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
{unique.map((source) => (
<SourcePill key={source.url} source={source} />
))}
</div>
</ToolCollapsible>
);
};
/**
* A single source citation pill. Shows a favicon from Google's S2
* service, a truncated title, and an external-link icon on hover.
*/
const SourcePill: FC<{ source: { url: string; title: string } }> = ({
source,
}) => {
let hostname: string;
try {
hostname = new URL(source.url).hostname;
} catch {
hostname = "";
}
const faviconUrl = hostname
? `https://www.google.com/s2/favicons?domain=${hostname}&sz=16`
: undefined;
// Use the title if available, otherwise fall back to the hostname.
const label = source.title || hostname || source.url;
return (
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
title={source.title || source.url}
className={cn(
"group inline-flex items-center gap-1.5 rounded-full",
"border border-solid border-border-default bg-surface-secondary",
"px-2.5 py-1 text-xs leading-none text-content-secondary",
"no-underline transition-colors",
"hover:bg-surface-tertiary hover:text-content-primary",
"hover:border-border-hover",
"max-w-[200px]",
)}
>
{faviconUrl && (
<img
src={faviconUrl}
alt=""
width={14}
height={14}
className="shrink-0 rounded-sm"
// Hide the broken-image icon if the favicon fails to load.
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
)}
<span className="truncate">{label}</span>
<ExternalLinkIcon className="h-3 w-3 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" />
</a>
);
};
export default WebSearchSources;
@@ -1 +1,2 @@
export { Tool } from "./Tool";
export { default as WebSearchSources } from "./WebSearchSources";
@@ -7,6 +7,7 @@ import {
Shimmer,
Tool,
} from "components/ai-elements";
import { WebSearchSources } from "components/ai-elements/tool";
import { FileIcon } from "components/FileIcon/FileIcon";
import { Spinner } from "components/Spinner/Spinner";
import { ChevronDownIcon } from "lucide-react";
@@ -267,6 +268,13 @@ function renderBlockList({
);
}
return null;
case "sources":
return (
<WebSearchSources
key={`${keyPrefix}-sources-${index}`}
sources={block.sources}
/>
);
default:
return null;
}
@@ -314,8 +322,22 @@ const ChatMessageItem = memo<{
return null;
}
// Hide messages that consist entirely of provider-executed
// tool results. The parser skips these parts, so the parsed
// output is empty and would show a "no renderable content"
// fallback.
const parts = message.content ?? [];
if (
parts.length > 0 &&
parts.every((p) => p.type === "tool-result" && p.provider_executed)
) {
return null;
}
const hasRenderableContent =
parsed.blocks.length > 0 || parsed.tools.length > 0;
parsed.blocks.length > 0 ||
parsed.tools.length > 0 ||
parsed.sources.length > 0;
const conversationItemProps: { role: "user" | "assistant" } = {
role: isUser ? "user" : "assistant",
};
@@ -69,6 +69,7 @@ export const StreamingWithText: Story = {
],
toolCalls: {},
toolResults: {},
sources: [],
},
streamTools: [],
},
@@ -86,6 +87,7 @@ export const StreamingAfterRetry: Story = {
],
toolCalls: {},
toolResults: {},
sources: [],
},
streamTools: [],
retryState: null,
@@ -76,6 +76,7 @@ const emptyParsedMessageContent = (): ParsedMessageContent => ({
toolResults: [],
tools: [],
blocks: [],
sources: [],
});
/** Wraps appendTextBlock with newline-joining for complete message blocks. */
@@ -180,6 +181,13 @@ export const parseMessageContent = (content: unknown): ParsedMessageContent => {
}
case "tool-call":
case "toolcall": {
// Provider-executed tool calls (e.g. web_search) are
// handled by the provider itself — hide them from the
// tool card UI and let the sources component render
// their results.
if (typedBlock.provider_executed) {
break;
}
const name =
asString(typedBlock.tool_name) || asString(typedBlock.name);
const id =
@@ -214,6 +222,10 @@ export const parseMessageContent = (content: unknown): ParsedMessageContent => {
}
case "tool-result":
case "toolresult": {
// Skip synthetic results for provider-executed tools.
if (typedBlock.provider_executed) {
break;
}
const name =
asString(typedBlock.tool_name) || asString(typedBlock.name);
const id =
@@ -251,6 +263,33 @@ export const parseMessageContent = (content: unknown): ParsedMessageContent => {
}
break;
}
case "source": {
const url = asString(typedBlock.url);
const title = asString(typedBlock.title);
if (url) {
const source = { url, title: title || url };
// Still populate the flat list for backward compat.
if (!parsed.sources.some((s) => s.url === url)) {
parsed.sources.push(source);
}
// Group consecutive sources into a single
// inline block at this position.
const lastBlock = parsed.blocks[parsed.blocks.length - 1];
if (
lastBlock &&
lastBlock.type === "sources" &&
!lastBlock.sources.some((s) => s.url === url)
) {
lastBlock.sources.push(source);
} else if (!lastBlock || lastBlock.type !== "sources") {
parsed.blocks.push({
type: "sources",
sources: [source],
});
}
}
break;
}
default: {
const text = asString(typedBlock.text);
parsed.markdown = appendText(parsed.markdown, text);
@@ -30,6 +30,7 @@ describe("applyMessagePartToStreamState", () => {
blocks: [{ type: "response", text: "Hello" }],
toolCalls: {},
toolResults: {},
sources: [],
};
const result = applyMessagePartToStreamState(prev, {
type: "text",
@@ -254,6 +255,7 @@ describe("buildStreamTools", () => {
"tc-1": { id: "tc-1", name: "bash", args: { cmd: "ls" } },
},
toolResults: {},
sources: [],
};
const tools = buildStreamTools(state);
expect(tools).toHaveLength(1);
@@ -274,6 +276,7 @@ describe("buildStreamTools", () => {
isError: false,
},
},
sources: [],
};
const tools = buildStreamTools(state);
expect(tools[0].status).toBe("completed");
@@ -291,6 +294,7 @@ describe("buildStreamTools", () => {
isError: false,
},
},
sources: [],
};
const tools = buildStreamTools(state);
expect(tools).toHaveLength(1);
@@ -7,7 +7,7 @@ import {
parseToolResultIsError,
} from "./messageParsing";
import { mergeStreamPayload } from "./streamingJson";
import type { MergedTool, StreamState } from "./types";
import type { MergedTool, RenderBlock, StreamState } from "./types";
let nextFallbackID = 0;
@@ -15,6 +15,7 @@ export const createEmptyStreamState = (): StreamState => ({
blocks: [],
toolCalls: {},
toolResults: {},
sources: [],
});
/** Streaming variant — uses direct concatenation (the default joinText). */
@@ -57,6 +58,12 @@ export const applyMessagePartToStreamState = (
}
case "tool-call":
case "toolcall": {
// Provider-executed tool calls (e.g. web_search) are
// handled natively by the provider — skip rendering them
// as tool cards.
if (part.provider_executed) {
return prev;
}
const toolName = asString(part.tool_name);
const existingByName = Object.values(nextState.toolCalls).find(
(call) => call.name === toolName,
@@ -89,6 +96,10 @@ export const applyMessagePartToStreamState = (
}
case "tool-result":
case "toolresult": {
// Skip synthetic results for provider-executed tools.
if (part.provider_executed) {
return prev;
}
const toolName = asString(part.tool_name);
const existingByName = Object.values(nextState.toolResults).find(
(result) => result.name === toolName,
@@ -150,6 +161,41 @@ export const applyMessagePartToStreamState = (
],
};
}
case "source": {
const url = asString(part.url);
const title = asString(part.title);
if (!url) {
return prev;
}
const source = { url, title: title || url };
// Still populate the flat list for backward compat.
if (nextState.sources.some((s) => s.url === url)) {
return prev;
}
const newSources = [...nextState.sources, source];
// Group consecutive sources into a single inline
// block at the current position in the block list.
const lastBlock = nextState.blocks[nextState.blocks.length - 1];
let newBlocks: RenderBlock[];
if (lastBlock && lastBlock.type === "sources") {
// Append to existing sources block.
newBlocks = [...nextState.blocks];
newBlocks[newBlocks.length - 1] = {
type: "sources",
sources: [...lastBlock.sources, source],
};
} else {
newBlocks = [
...nextState.blocks,
{ type: "sources", sources: [source] },
];
}
return {
...nextState,
sources: newSources,
blocks: newBlocks,
};
}
default:
return prev;
}
@@ -49,6 +49,10 @@ export type RenderBlock =
endLine: number;
content: string;
text: string;
}
| {
type: "sources";
sources: Array<{ url: string; title: string }>;
};
export type ParsedMessageContent = {
@@ -58,6 +62,7 @@ export type ParsedMessageContent = {
toolResults: ParsedToolResult[];
tools: MergedTool[];
blocks: RenderBlock[];
sources: Array<{ url: string; title: string }>;
};
export type ParsedMessageEntry = {
@@ -89,4 +94,5 @@ export type StreamState = {
blocks: RenderBlock[];
toolCalls: Record<string, StreamToolCall>;
toolResults: Record<string, StreamToolResult>;
sources: Array<{ url: string; title: string }>;
};
@@ -17,6 +17,7 @@ const createStreamState = (
blocks: [],
toolCalls,
toolResults,
sources: [],
});
type MinimalChatStoreState = Pick<