diff --git a/internal/apischema/anthropic/anthropic.go b/internal/apischema/anthropic/anthropic.go index a73511d870..47612b08d2 100644 --- a/internal/apischema/anthropic/anthropic.go +++ b/internal/apischema/anthropic/anthropic.go @@ -76,7 +76,7 @@ type MessagesRequest struct { // Tools is the list of tools available to the model. // https://docs.claude.com/en/api/messages#body-tools - Tools []Tool `json:"tools,omitempty"` + Tools []ToolUnion `json:"tools,omitempty"` // Stream indicates whether to stream the response. Stream bool `json:"stream,omitempty"` @@ -143,46 +143,857 @@ func (m *MessageContent) MarshalJSON() ([]byte, error) { type ( // ContentBlockParam represents an element of the array content in a message. - // https://docs.claude.com/en/api/messages#body-messages-content + // https://platform.claude.com/docs/en/api/messages#body-messages-content ContentBlockParam struct { - Text *TextBlockParam - // TODO add others when we need it for observability, etc. + Text *TextBlockParam + Image *ImageBlockParam + Document *DocumentBlockParam + SearchResult *SearchResultBlockParam + Thinking *ThinkingBlockParam + RedactedThinking *RedactedThinkingBlockParam + ToolUse *ToolUseBlockParam + ToolResult *ToolResultBlockParam + ServerToolUse *ServerToolUseBlockParam + WebSearchToolResult *WebSearchToolResultBlockParam } + // TextBlockParam represents a text content block. + // https://platform.claude.com/docs/en/api/messages#text_block_param TextBlockParam struct { - Text string `json:"text"` - Type string `json:"type"` // Always "text". - CacheControl any `json:"cache_control,omitempty"` - Citations []any `json:"citations,omitempty"` + Text string `json:"text"` + Type string `json:"type"` // Always "text". + CacheControl *CacheControl `json:"cache_control,omitempty"` + Citations []TextCitation `json:"citations,omitempty"` + } + + // ImageBlockParam represents an image content block. + // https://platform.claude.com/docs/en/api/messages#image_block_param + ImageBlockParam struct { + Type string `json:"type"` // Always "image". + Source ImageSource `json:"source"` + CacheControl *CacheControl `json:"cache_control,omitempty"` + } + + // DocumentBlockParam represents a document content block. + // https://platform.claude.com/docs/en/api/messages#document_block_param + DocumentBlockParam struct { + Type string `json:"type"` // Always "document". + Source DocumentSource `json:"source"` + CacheControl *CacheControl `json:"cache_control,omitempty"` + Citations *CitationsConfigParam `json:"citations,omitempty"` + Context string `json:"context,omitempty"` + Title string `json:"title,omitempty"` + } + + // SearchResultBlockParam represents a search result content block. + // https://platform.claude.com/docs/en/api/messages#search_result_block_param + SearchResultBlockParam struct { + Type string `json:"type"` // Always "search_result". + Content []TextBlockParam `json:"content"` + Source string `json:"source"` + Title string `json:"title"` + CacheControl *CacheControl `json:"cache_control,omitempty"` + Citations *CitationsConfigParam `json:"citations,omitempty"` + } + + // ThinkingBlockParam represents a thinking content block in a request. + // https://platform.claude.com/docs/en/api/messages#thinking_block_param + ThinkingBlockParam struct { + Type string `json:"type"` // Always "thinking". + Thinking string `json:"thinking"` + Signature string `json:"signature"` + } + + // RedactedThinkingBlockParam represents a redacted thinking content block. + // https://platform.claude.com/docs/en/api/messages#redacted_thinking_block_param + RedactedThinkingBlockParam struct { + Type string `json:"type"` // Always "redacted_thinking". + Data string `json:"data"` + } + + // ToolUseBlockParam represents a tool use content block in a request. + // https://platform.claude.com/docs/en/api/messages#tool_use_block_param + ToolUseBlockParam struct { + Type string `json:"type"` // Always "tool_use". + ID string `json:"id"` + Name string `json:"name"` + Input map[string]any `json:"input"` + CacheControl *CacheControl `json:"cache_control,omitempty"` + } + + // ToolResultBlockParam represents a tool result content block. + // https://platform.claude.com/docs/en/api/messages#tool_result_block_param + ToolResultBlockParam struct { + Type string `json:"type"` // Always "tool_result". + ToolUseID string `json:"tool_use_id"` + Content *ToolResultContent `json:"content,omitempty"` // string or array of content blocks. + IsError bool `json:"is_error,omitempty"` + CacheControl *CacheControl `json:"cache_control,omitempty"` + } + + // ToolResultContent represents the content of a tool result block, + // which can be a string or an array of text, image, search result, or document blocks. + // https://platform.claude.com/docs/en/api/messages#tool_result_block_param + ToolResultContent struct { + Text string // Non-empty if this is plain text content. + Array []ToolResultContentItem // Non-empty if this is array content. + } + + // ToolResultContentItem is a single content block in a tool result array. + // https://platform.claude.com/docs/en/api/messages#tool_result_block_param + ToolResultContentItem struct { + Text *TextBlockParam + Image *ImageBlockParam + SearchResult *SearchResultBlockParam + Document *DocumentBlockParam + } + + // ServerToolUseBlockParam represents a server tool use content block. + // https://platform.claude.com/docs/en/api/messages#server_tool_use_block_param + ServerToolUseBlockParam struct { + Type string `json:"type"` // Always "server_tool_use". + ID string `json:"id"` + Name string `json:"name"` + Input map[string]any `json:"input"` + CacheControl *CacheControl `json:"cache_control,omitempty"` + } + + // WebSearchToolResultBlockParam represents a web search tool result content block. + // https://platform.claude.com/docs/en/api/messages#web_search_tool_result_block_param + WebSearchToolResultBlockParam struct { + Type string `json:"type"` // Always "web_search_tool_result". + ToolUseID string `json:"tool_use_id"` + Content WebSearchToolResultContent `json:"content"` + CacheControl *CacheControl `json:"cache_control,omitempty"` } ) +// Content block type constants used by ContentBlockParam and MessagesContentBlock. +const ( + contentBlockTypeText = "text" + contentBlockTypeImage = "image" + contentBlockTypeDocument = "document" + contentBlockTypeSearchResult = "search_result" + contentBlockTypeThinking = "thinking" + contentBlockTypeRedactedThinking = "redacted_thinking" + contentBlockTypeToolUse = "tool_use" + contentBlockTypeToolResult = "tool_result" + contentBlockTypeServerToolUse = "server_tool_use" + contentBlockTypeWebSearchToolResult = "web_search_tool_result" +) + func (m *ContentBlockParam) UnmarshalJSON(data []byte) error { typ := gjson.GetBytes(data, "type") if !typ.Exists() { return errors.New("missing type field in message content block") } switch typ.String() { - case "text": - var textBlock TextBlockParam - if err := json.Unmarshal(data, &textBlock); err != nil { - return fmt.Errorf("failed to unmarshal text block: %w", err) + case contentBlockTypeText: + var blockParam TextBlockParam + if err := json.Unmarshal(data, &blockParam); err != nil { + return fmt.Errorf("failed to unmarshal text blockParam: %w", err) } - m.Text = &textBlock - return nil + m.Text = &blockParam + case contentBlockTypeImage: + var blockParam ImageBlockParam + if err := json.Unmarshal(data, &blockParam); err != nil { + return fmt.Errorf("failed to unmarshal image blockParam: %w", err) + } + m.Image = &blockParam + case contentBlockTypeDocument: + var blockParam DocumentBlockParam + if err := json.Unmarshal(data, &blockParam); err != nil { + return fmt.Errorf("failed to unmarshal document blockParam: %w", err) + } + m.Document = &blockParam + case contentBlockTypeSearchResult: + var blockParam SearchResultBlockParam + if err := json.Unmarshal(data, &blockParam); err != nil { + return fmt.Errorf("failed to unmarshal search result blockParam: %w", err) + } + m.SearchResult = &blockParam + case contentBlockTypeThinking: + var blockParam ThinkingBlockParam + if err := json.Unmarshal(data, &blockParam); err != nil { + return fmt.Errorf("failed to unmarshal thinking blockParam: %w", err) + } + m.Thinking = &blockParam + case contentBlockTypeRedactedThinking: + var blockParam RedactedThinkingBlockParam + if err := json.Unmarshal(data, &blockParam); err != nil { + return fmt.Errorf("failed to unmarshal redacted thinking blockParam: %w", err) + } + m.RedactedThinking = &blockParam + case contentBlockTypeToolUse: + var blockParam ToolUseBlockParam + if err := json.Unmarshal(data, &blockParam); err != nil { + return fmt.Errorf("failed to unmarshal tool use blockParam: %w", err) + } + m.ToolUse = &blockParam + case contentBlockTypeToolResult: + var blockParam ToolResultBlockParam + if err := json.Unmarshal(data, &blockParam); err != nil { + return fmt.Errorf("failed to unmarshal tool result blockParam: %w", err) + } + m.ToolResult = &blockParam + case contentBlockTypeServerToolUse: + var blockParam ServerToolUseBlockParam + if err := json.Unmarshal(data, &blockParam); err != nil { + return fmt.Errorf("failed to unmarshal server tool use blockParam: %w", err) + } + m.ServerToolUse = &blockParam + case contentBlockTypeWebSearchToolResult: + var blockParam WebSearchToolResultBlockParam + if err := json.Unmarshal(data, &blockParam); err != nil { + return fmt.Errorf("failed to unmarshal web search tool result blockParam: %w", err) + } + m.WebSearchToolResult = &blockParam default: - // TODO add others when we need it for observability, etc. - // Fow now, we ignore undefined types. + // Ignore unknown types for forward compatibility. return nil } + return nil } func (m *ContentBlockParam) MarshalJSON() ([]byte, error) { if m.Text != nil { return json.Marshal(m.Text) } - // TODO add others when we need it for observability, etc. - return nil, fmt.Errorf("content block must have a defined type") + if m.Image != nil { + return json.Marshal(m.Image) + } + if m.Document != nil { + return json.Marshal(m.Document) + } + if m.SearchResult != nil { + return json.Marshal(m.SearchResult) + } + if m.Thinking != nil { + return json.Marshal(m.Thinking) + } + if m.RedactedThinking != nil { + return json.Marshal(m.RedactedThinking) + } + if m.ToolUse != nil { + return json.Marshal(m.ToolUse) + } + if m.ToolResult != nil { + return json.Marshal(m.ToolResult) + } + if m.ServerToolUse != nil { + return json.Marshal(m.ServerToolUse) + } + if m.WebSearchToolResult != nil { + return json.Marshal(m.WebSearchToolResult) + } + return nil, fmt.Errorf("content block param must have a defined type") +} + +func (c *ToolResultContent) UnmarshalJSON(data []byte) error { + var text string + if err := json.Unmarshal(data, &text); err == nil { + c.Text = text + return nil + } + var array []ToolResultContentItem + if err := json.Unmarshal(data, &array); err == nil { + c.Array = array + return nil + } + return fmt.Errorf("tool result content must be either text or array of content blocks") +} + +func (c *ToolResultContent) MarshalJSON() ([]byte, error) { + if c.Text != "" { + return json.Marshal(c.Text) + } + if len(c.Array) > 0 { + return json.Marshal(c.Array) + } + return nil, fmt.Errorf("tool result content must have either text or array") +} + +func (item *ToolResultContentItem) UnmarshalJSON(data []byte) error { + typ := gjson.GetBytes(data, "type") + if !typ.Exists() { + return errors.New("missing type field in tool result content item") + } + switch typ.String() { + case contentBlockTypeText: + var block TextBlockParam + if err := json.Unmarshal(data, &block); err != nil { + return fmt.Errorf("failed to unmarshal text block in tool result: %w", err) + } + item.Text = &block + case contentBlockTypeImage: + var block ImageBlockParam + if err := json.Unmarshal(data, &block); err != nil { + return fmt.Errorf("failed to unmarshal image block in tool result: %w", err) + } + item.Image = &block + case contentBlockTypeSearchResult: + var block SearchResultBlockParam + if err := json.Unmarshal(data, &block); err != nil { + return fmt.Errorf("failed to unmarshal search result block in tool result: %w", err) + } + item.SearchResult = &block + case contentBlockTypeDocument: + var block DocumentBlockParam + if err := json.Unmarshal(data, &block); err != nil { + return fmt.Errorf("failed to unmarshal document block in tool result: %w", err) + } + item.Document = &block + default: + // Ignore unknown types for forward compatibility. + } + return nil +} + +func (item ToolResultContentItem) MarshalJSON() ([]byte, error) { + if item.Text != nil { + return json.Marshal(item.Text) + } + if item.Image != nil { + return json.Marshal(item.Image) + } + if item.SearchResult != nil { + return json.Marshal(item.SearchResult) + } + if item.Document != nil { + return json.Marshal(item.Document) + } + return nil, fmt.Errorf("tool result content item must have a defined type") +} + +// CacheControl represents a cache control configuration. +// https://platform.claude.com/docs/en/api/messages#cache_control_ephemeral +type CacheControl struct { + Ephemeral *CacheControlEphemeral +} + +// CacheControlEphemeral represents an ephemeral cache control breakpoint. +// https://platform.claude.com/docs/en/api/messages#cache_control_ephemeral +type CacheControlEphemeral struct { + // Type is always "ephemeral". + Type string `json:"type"` + // TTL is the time-to-live for the cache entry. Valid values: "5m" or "1h". Defaults to "5m". + TTL *string `json:"ttl,omitempty"` +} + +const cacheControlTypeEphemeral = "ephemeral" + +func (c *CacheControl) UnmarshalJSON(data []byte) error { + typ := gjson.GetBytes(data, "type") + if !typ.Exists() { + return errors.New("missing type field in cache control") + } + switch typ.String() { + case cacheControlTypeEphemeral: + var cc CacheControlEphemeral + if err := json.Unmarshal(data, &cc); err != nil { + return fmt.Errorf("failed to unmarshal cache control ephemeral: %w", err) + } + c.Ephemeral = &cc + default: + // Ignore unknown types for forward compatibility. + } + return nil +} + +func (c *CacheControl) MarshalJSON() ([]byte, error) { + if c.Ephemeral != nil { + return json.Marshal(c.Ephemeral) + } + return nil, fmt.Errorf("cache control must have a defined type") +} + +type ( + // ImageSource represents the source of an image content block. + // https://platform.claude.com/docs/en/api/messages#image_block_param + ImageSource struct { + Base64 *Base64ImageSource + URL *URLImageSource + } + + // Base64ImageSource represents a base64-encoded image. + // https://platform.claude.com/docs/en/api/messages#base64_image_source + Base64ImageSource struct { + // Type is always "base64". + Type string `json:"type"` + // MediaType is the MIME type of the image. Valid values: "image/jpeg", "image/png", "image/gif", "image/webp". + MediaType string `json:"media_type"` + // Data is the base64-encoded image data. + Data string `json:"data"` + } + + // URLImageSource represents an image from a URL. + // https://platform.claude.com/docs/en/api/messages#url_image_source + URLImageSource struct { + // Type is always "url". + Type string `json:"type"` + // URL is the URL of the image. + URL string `json:"url"` + } +) + +// Image source type constants. +const ( + imageSourceTypeBase64 = "base64" + imageSourceTypeURL = "url" +) + +func (s *ImageSource) UnmarshalJSON(data []byte) error { + typ := gjson.GetBytes(data, "type") + if !typ.Exists() { + return errors.New("missing type field in image source") + } + switch typ.String() { + case imageSourceTypeBase64: + var src Base64ImageSource + if err := json.Unmarshal(data, &src); err != nil { + return fmt.Errorf("failed to unmarshal base64 image source: %w", err) + } + s.Base64 = &src + case imageSourceTypeURL: + var src URLImageSource + if err := json.Unmarshal(data, &src); err != nil { + return fmt.Errorf("failed to unmarshal URL image source: %w", err) + } + s.URL = &src + default: + // Ignore unknown types for forward compatibility. + } + return nil +} + +func (s ImageSource) MarshalJSON() ([]byte, error) { + if s.Base64 != nil { + return json.Marshal(s.Base64) + } + if s.URL != nil { + return json.Marshal(s.URL) + } + return nil, fmt.Errorf("image source must have a defined type") +} + +type ( + // DocumentSource represents the source of a document content block. + // https://platform.claude.com/docs/en/api/messages#document_block_param + DocumentSource struct { + Base64PDF *Base64PDFSource + PlainText *PlainTextSource + URL *URLPDFSource + ContentBlock *ContentBlockSource + } + + // Base64PDFSource represents a base64-encoded PDF document. + // https://platform.claude.com/docs/en/api/messages#base64_pdf_source + Base64PDFSource struct { + // Type is always "base64". + Type string `json:"type"` + // MediaType is always "application/pdf". + MediaType string `json:"media_type"` + // Data is the base64-encoded PDF data. + Data string `json:"data"` + } + + // PlainTextSource represents a plain text document. + // https://platform.claude.com/docs/en/api/messages#plain_text_source + PlainTextSource struct { + // Type is always "text". + Type string `json:"type"` + // MediaType is always "text/plain". + MediaType string `json:"media_type"` + // Data is the plain text content. + Data string `json:"data"` + } + + // URLPDFSource represents a PDF document from a URL. + // https://platform.claude.com/docs/en/api/messages#url_pdf_source + URLPDFSource struct { + // Type is always "url". + Type string `json:"type"` + // URL is the URL of the PDF document. + URL string `json:"url"` + } + + // ContentBlockSource represents a document sourced from content blocks. + // https://platform.claude.com/docs/en/api/messages#content_block_source + ContentBlockSource struct { + // Type is always "content". + Type string `json:"type"` + // Content is the content of the document, either a string or an array of content blocks. + Content ContentBlockSourceContent `json:"content"` + } + + // ContentBlockSourceContent is the content of a ContentBlockSource, which can be + // a string or an array of text/image content blocks. + ContentBlockSourceContent struct { + Text string // Non-empty if this is plain string content. + Array []ContentBlockSourceItem // Non-empty if this is array content. + } + + // ContentBlockSourceItem is a single content block in a ContentBlockSource array. + // It can be a text or image block. + ContentBlockSourceItem struct { + Text *TextBlockParam + Image *ImageBlockParam + } +) + +// Document source type constants. +const ( + documentSourceTypeBase64 = "base64" + documentSourceTypeText = "text" + documentSourceTypeURL = "url" + documentSourceTypeContent = "content" +) + +func (s *DocumentSource) UnmarshalJSON(data []byte) error { + typ := gjson.GetBytes(data, "type") + if !typ.Exists() { + return errors.New("missing type field in document source") + } + switch typ.String() { + case documentSourceTypeBase64: + var src Base64PDFSource + if err := json.Unmarshal(data, &src); err != nil { + return fmt.Errorf("failed to unmarshal base64 PDF source: %w", err) + } + s.Base64PDF = &src + case documentSourceTypeText: + var src PlainTextSource + if err := json.Unmarshal(data, &src); err != nil { + return fmt.Errorf("failed to unmarshal plain text source: %w", err) + } + s.PlainText = &src + case documentSourceTypeURL: + var src URLPDFSource + if err := json.Unmarshal(data, &src); err != nil { + return fmt.Errorf("failed to unmarshal URL PDF source: %w", err) + } + s.URL = &src + case documentSourceTypeContent: + var src ContentBlockSource + if err := json.Unmarshal(data, &src); err != nil { + return fmt.Errorf("failed to unmarshal content block source: %w", err) + } + s.ContentBlock = &src + default: + // Ignore unknown types for forward compatibility. + } + return nil +} + +func (s DocumentSource) MarshalJSON() ([]byte, error) { + if s.Base64PDF != nil { + return json.Marshal(s.Base64PDF) + } + if s.PlainText != nil { + return json.Marshal(s.PlainText) + } + if s.URL != nil { + return json.Marshal(s.URL) + } + if s.ContentBlock != nil { + return json.Marshal(s.ContentBlock) + } + return nil, fmt.Errorf("document source must have a defined type") +} + +func (c *ContentBlockSourceContent) UnmarshalJSON(data []byte) error { + // Try to unmarshal as string first. + var text string + if err := json.Unmarshal(data, &text); err == nil { + c.Text = text + return nil + } + // Try to unmarshal as array of content blocks. + var array []ContentBlockSourceItem + if err := json.Unmarshal(data, &array); err == nil { + c.Array = array + return nil + } + return fmt.Errorf("content block source content must be either text or array") +} + +func (c ContentBlockSourceContent) MarshalJSON() ([]byte, error) { + if c.Text != "" { + return json.Marshal(c.Text) + } + if len(c.Array) > 0 { + return json.Marshal(c.Array) + } + return nil, fmt.Errorf("content block source content must have either text or array") +} + +func (item *ContentBlockSourceItem) UnmarshalJSON(data []byte) error { + typ := gjson.GetBytes(data, "type") + if !typ.Exists() { + return errors.New("missing type field in content block source item") + } + switch typ.String() { + case contentBlockTypeText: + var block TextBlockParam + if err := json.Unmarshal(data, &block); err != nil { + return fmt.Errorf("failed to unmarshal text block in content block source: %w", err) + } + item.Text = &block + case contentBlockTypeImage: + var block ImageBlockParam + if err := json.Unmarshal(data, &block); err != nil { + return fmt.Errorf("failed to unmarshal image block in content block source: %w", err) + } + item.Image = &block + default: + // Ignore unknown types for forward compatibility. + } + return nil +} + +func (item ContentBlockSourceItem) MarshalJSON() ([]byte, error) { + if item.Text != nil { + return json.Marshal(item.Text) + } + if item.Image != nil { + return json.Marshal(item.Image) + } + return nil, fmt.Errorf("content block source item must have a defined type") +} + +// CitationsConfigParam enables or disables citations for a document or search result block. +// https://platform.claude.com/docs/en/api/messages#citations_config_param +type CitationsConfigParam struct { + // Enabled indicates whether citations are enabled. + Enabled *bool `json:"enabled,omitempty"` +} + +type ( + // TextCitation represents a single citation in a text block, used in both requests + // and responses. + // https://platform.claude.com/docs/en/api/messages#text_citation_param + TextCitation struct { + CharLocation *CitationCharLocation + PageLocation *CitationPageLocation + ContentBlockLocation *CitationContentBlockLocation + WebSearchResultLocation *CitationWebSearchResultLocation + SearchResultLocation *CitationSearchResultLocation + } + + // CitationCharLocation represents a citation with character-level location in a document. + // https://platform.claude.com/docs/en/api/messages#citation_char_location + CitationCharLocation struct { + // Type is always "char_location". + Type string `json:"type"` + // CitedText is the exact text being cited. + CitedText string `json:"cited_text"` + // DocumentIndex is the index of the document being cited in the documents array. + DocumentIndex int `json:"document_index"` + // DocumentTitle is the title of the cited document. + DocumentTitle *string `json:"document_title,omitempty"` + // StartCharIndex is the start character index of the citation within the document. + StartCharIndex int `json:"start_char_index"` + // EndCharIndex is the end character index of the citation within the document. + EndCharIndex int `json:"end_char_index"` + } + + // CitationPageLocation represents a citation with page-level location in a document. + // https://platform.claude.com/docs/en/api/messages#citation_page_location + CitationPageLocation struct { + // Type is always "page_location". + Type string `json:"type"` + // CitedText is the exact text being cited. + CitedText string `json:"cited_text"` + // DocumentIndex is the index of the document being cited in the documents array. + DocumentIndex int `json:"document_index"` + // DocumentTitle is the title of the cited document. + DocumentTitle *string `json:"document_title,omitempty"` + // StartPageNumber is the 1-indexed start page number of the citation. + StartPageNumber int `json:"start_page_number"` + // EndPageNumber is the 1-indexed end page number of the citation. + EndPageNumber int `json:"end_page_number"` + } + + // CitationContentBlockLocation represents a citation with content-block-level location. + // https://platform.claude.com/docs/en/api/messages#citation_content_block_location + CitationContentBlockLocation struct { + // Type is always "content_block_location". + Type string `json:"type"` + // CitedText is the exact text being cited. + CitedText string `json:"cited_text"` + // DocumentIndex is the index of the document being cited in the documents array. + DocumentIndex int `json:"document_index"` + // DocumentTitle is the title of the cited document. + DocumentTitle *string `json:"document_title,omitempty"` + // StartBlockIndex is the start block index of the citation within the document. + StartBlockIndex int `json:"start_block_index"` + // EndBlockIndex is the end block index of the citation within the document. + EndBlockIndex int `json:"end_block_index"` + } + + // CitationWebSearchResultLocation represents a citation from a web search result. + // https://platform.claude.com/docs/en/api/messages#citation_web_search_result_location + CitationWebSearchResultLocation struct { + // Type is always "web_search_result_location". + Type string `json:"type"` + // CitedText is the exact text being cited. + CitedText string `json:"cited_text"` + // EncryptedIndex is the encrypted index of the web search result. + EncryptedIndex string `json:"encrypted_index"` + // Title is the title of the web page. + Title string `json:"title,omitempty"` + // URL is the URL of the web page. + URL string `json:"url"` + } + + // CitationSearchResultLocation represents a citation from a search result block. + // https://platform.claude.com/docs/en/api/messages#citation_search_result_location + CitationSearchResultLocation struct { + // Type is always "search_result_location". + Type string `json:"type"` + // CitedText is the exact text being cited. + CitedText string `json:"cited_text"` + // Title is the title of the search result. + Title string `json:"title,omitempty"` + // Source is the source URL or identifier of the search result. + Source string `json:"source"` + // StartBlockIndex is the start block index within the search result. + StartBlockIndex int `json:"start_block_index"` + // EndBlockIndex is the end block index within the search result. + EndBlockIndex int `json:"end_block_index"` + // SearchResultIndex is the index of the search result in the search results array. + SearchResultIndex int `json:"search_result_index"` + } +) + +// Citation type constants. +const ( + citationTypeCharLocation = "char_location" + citationTypePageLocation = "page_location" + citationTypeContentBlockLocation = "content_block_location" + citationTypeWebSearchResultLocation = "web_search_result_location" + citationTypeSearchResultLocation = "search_result_location" +) + +func (c *TextCitation) UnmarshalJSON(data []byte) error { + typ := gjson.GetBytes(data, "type") + if !typ.Exists() { + return errors.New("missing type field in text citation") + } + switch typ.String() { + case citationTypeCharLocation: + var citation CitationCharLocation + if err := json.Unmarshal(data, &citation); err != nil { + return fmt.Errorf("failed to unmarshal char location citation: %w", err) + } + c.CharLocation = &citation + case citationTypePageLocation: + var citation CitationPageLocation + if err := json.Unmarshal(data, &citation); err != nil { + return fmt.Errorf("failed to unmarshal page location citation: %w", err) + } + c.PageLocation = &citation + case citationTypeContentBlockLocation: + var citation CitationContentBlockLocation + if err := json.Unmarshal(data, &citation); err != nil { + return fmt.Errorf("failed to unmarshal content block location citation: %w", err) + } + c.ContentBlockLocation = &citation + case citationTypeWebSearchResultLocation: + var citation CitationWebSearchResultLocation + if err := json.Unmarshal(data, &citation); err != nil { + return fmt.Errorf("failed to unmarshal web search result location citation: %w", err) + } + c.WebSearchResultLocation = &citation + case citationTypeSearchResultLocation: + var citation CitationSearchResultLocation + if err := json.Unmarshal(data, &citation); err != nil { + return fmt.Errorf("failed to unmarshal search result location citation: %w", err) + } + c.SearchResultLocation = &citation + default: + // Ignore unknown types for forward compatibility. + } + return nil +} + +func (c *TextCitation) MarshalJSON() ([]byte, error) { + if c.CharLocation != nil { + return json.Marshal(c.CharLocation) + } + if c.PageLocation != nil { + return json.Marshal(c.PageLocation) + } + if c.ContentBlockLocation != nil { + return json.Marshal(c.ContentBlockLocation) + } + if c.WebSearchResultLocation != nil { + return json.Marshal(c.WebSearchResultLocation) + } + if c.SearchResultLocation != nil { + return json.Marshal(c.SearchResultLocation) + } + return nil, fmt.Errorf("text citation must have a defined type") +} + +type ( + // WebSearchToolResultContent is the content of a web search tool result block. + // It can be an array of web search results or a single error. + WebSearchToolResultContent struct { + Results []WebSearchResult // Non-nil if content is an array of results. + Error *WebSearchToolResultError // Non-nil if content is an error. + } + + // WebSearchResult represents a single web search result. + // https://platform.claude.com/docs/en/api/messages#web_search_result + WebSearchResult struct { + // Type is always "web_search_result". + Type string `json:"type"` + // Title is the title of the web page. + Title string `json:"title"` + // URL is the URL of the web page. + URL string `json:"url"` + // EncryptedContent is the encrypted content of the web page. + EncryptedContent string `json:"encrypted_content"` + // PageAge is an optional age indicator for the page (e.g. "2 days ago"). + PageAge *string `json:"page_age,omitempty"` + } + + // WebSearchToolResultError represents an error in a web search tool result. + // https://platform.claude.com/docs/en/api/messages#web_search_tool_result_error + WebSearchToolResultError struct { + // Type is always "web_search_tool_result_error". + Type string `json:"type"` + // ErrorCode is the error code. Valid values: "invalid_tool_input", "unavailable", + // "max_uses_exceeded", "too_many_requests", "query_too_long", "request_too_large". + ErrorCode string `json:"error_code"` + } +) + +func (w *WebSearchToolResultContent) UnmarshalJSON(data []byte) error { + // Try to unmarshal as array of WebSearchResult first. + var results []WebSearchResult + if err := json.Unmarshal(data, &results); err == nil { + w.Results = results + return nil + } + // Try to unmarshal as a single WebSearchToolResultError. + var wsError WebSearchToolResultError + if err := json.Unmarshal(data, &wsError); err == nil { + w.Error = &wsError + return nil + } + return fmt.Errorf("web search tool result content must be an array of results or an error") +} + +func (w WebSearchToolResultContent) MarshalJSON() ([]byte, error) { + if w.Results != nil { + return json.Marshal(w.Results) + } + if w.Error != nil { + return json.Marshal(w.Error) + } + return nil, fmt.Errorf("web search tool result content must have either results or an error") } // MessagesMetadata represents the metadata for the Anthropic Messages API request. @@ -203,24 +1014,86 @@ const ( ) // Container represents a container identifier for reuse across requests. -// https://docs.claude.com/en/api/messages#body-container +// This became a beta status so it is not implemented for now. +// https://platform.claude.com/docs/en/api/beta/messages/create type Container any // TODO when we need it for observability, etc. type ( // ToolUnion represents a tool available to the model. // https://platform.claude.com/docs/en/api/messages#tool_union ToolUnion struct { - Tool *Tool - // TODO when we need it for observability, etc. + Tool *Tool + BashTool *BashTool + TextEditorTool20250124 *TextEditorTool20250124 + TextEditorTool20250429 *TextEditorTool20250429 + TextEditorTool20250728 *TextEditorTool20250728 + WebSearchTool *WebSearchTool } + + // Tool represents a custom tool definition. + // https://platform.claude.com/docs/en/api/messages#tool Tool struct { Type string `json:"type"` // Always "custom". Name string `json:"name"` InputSchema ToolInputSchema `json:"input_schema"` - CacheControl any `json:"cache_schema,omitempty"` + CacheControl *CacheControl `json:"cache_control,omitempty"` Description string `json:"description,omitempty"` } + // BashTool represents the bash tool for computer use. + // https://platform.claude.com/docs/en/api/messages#tool_bash_20250124 + BashTool struct { + Type string `json:"type"` // Always "bash_20250124". + Name string `json:"name"` // Always "bash". + CacheControl *CacheControl `json:"cache_control,omitempty"` + } + + // TextEditorTool20250124 represents the text editor tool (v1). + // https://platform.claude.com/docs/en/api/messages#tool_text_editor_20250124 + TextEditorTool20250124 struct { + Type string `json:"type"` // Always "text_editor_20250124". + Name string `json:"name"` // Always "str_replace_editor". + CacheControl *CacheControl `json:"cache_control,omitempty"` + } + + // TextEditorTool20250429 represents the text editor tool (v2). + // https://platform.claude.com/docs/en/api/messages#tool_text_editor_20250429 + TextEditorTool20250429 struct { + Type string `json:"type"` // Always "text_editor_20250429". + Name string `json:"name"` // Always "str_replace_based_edit_tool". + CacheControl *CacheControl `json:"cache_control,omitempty"` + } + + // TextEditorTool20250728 represents the text editor tool (v3). + // https://platform.claude.com/docs/en/api/messages#tool_text_editor_20250728 + TextEditorTool20250728 struct { + Type string `json:"type"` // Always "text_editor_20250728". + Name string `json:"name"` // Always "str_replace_based_edit_tool". + MaxCharacters *float64 `json:"max_characters,omitempty"` + CacheControl *CacheControl `json:"cache_control,omitempty"` + } + + // WebSearchTool represents the web search tool. + // https://platform.claude.com/docs/en/api/messages#web_search_tool_20250305 + WebSearchTool struct { + Type string `json:"type"` // Always "web_search_20250305". + Name string `json:"name"` // Always "web_search". + AllowedDomains []string `json:"allowed_domains,omitempty"` + BlockedDomains []string `json:"blocked_domains,omitempty"` + MaxUses *float64 `json:"max_uses,omitempty"` + UserLocation *WebSearchLocation `json:"user_location,omitempty"` + CacheControl *CacheControl `json:"cache_control,omitempty"` + } + + // WebSearchLocation represents the user location for the web search tool. + WebSearchLocation struct { + Type string `json:"type"` // Always "approximate". + City string `json:"city,omitempty"` + Country string `json:"country,omitempty"` + Region string `json:"region,omitempty"` + Timezone string `json:"timezone,omitempty"` + } + ToolInputSchema struct { Type string `json:"type"` // Always "object". Properties map[string]any `json:"properties,omitempty"` @@ -228,33 +1101,267 @@ type ( } ) +// Tool type constants used by ToolUnion. +const ( + toolTypeCustom = "custom" + toolTypeBash20250124 = "bash_20250124" + toolTypeTextEditor20250124 = "text_editor_20250124" + toolTypeTextEditor20250429 = "text_editor_20250429" + toolTypeTextEditor20250728 = "text_editor_20250728" + toolTypeWebSearch20250305 = "web_search_20250305" +) + func (t *ToolUnion) UnmarshalJSON(data []byte) error { typ := gjson.GetBytes(data, "type") if !typ.Exists() { return errors.New("missing type field in tool") } switch typ.String() { - case "custom": + case toolTypeCustom: var tool Tool if err := json.Unmarshal(data, &tool); err != nil { return fmt.Errorf("failed to unmarshal tool: %w", err) } t.Tool = &tool + case toolTypeBash20250124: + var tool BashTool + if err := json.Unmarshal(data, &tool); err != nil { + return fmt.Errorf("failed to unmarshal bash tool: %w", err) + } + t.BashTool = &tool + case toolTypeTextEditor20250124: + var tool TextEditorTool20250124 + if err := json.Unmarshal(data, &tool); err != nil { + return fmt.Errorf("failed to unmarshal text editor tool: %w", err) + } + t.TextEditorTool20250124 = &tool + case toolTypeTextEditor20250429: + var tool TextEditorTool20250429 + if err := json.Unmarshal(data, &tool); err != nil { + return fmt.Errorf("failed to unmarshal text editor tool: %w", err) + } + t.TextEditorTool20250429 = &tool + case toolTypeTextEditor20250728: + var tool TextEditorTool20250728 + if err := json.Unmarshal(data, &tool); err != nil { + return fmt.Errorf("failed to unmarshal text editor tool: %w", err) + } + t.TextEditorTool20250728 = &tool + case toolTypeWebSearch20250305: + var tool WebSearchTool + if err := json.Unmarshal(data, &tool); err != nil { + return fmt.Errorf("failed to unmarshal web search tool: %w", err) + } + t.WebSearchTool = &tool + default: + // Ignore unknown types for forward compatibility. return nil + } + return nil +} + +func (t *ToolUnion) MarshalJSON() ([]byte, error) { + if t.Tool != nil { + return json.Marshal(t.Tool) + } + if t.BashTool != nil { + return json.Marshal(t.BashTool) + } + if t.TextEditorTool20250124 != nil { + return json.Marshal(t.TextEditorTool20250124) + } + if t.TextEditorTool20250429 != nil { + return json.Marshal(t.TextEditorTool20250429) + } + if t.TextEditorTool20250728 != nil { + return json.Marshal(t.TextEditorTool20250728) + } + if t.WebSearchTool != nil { + return json.Marshal(t.WebSearchTool) + } + return nil, fmt.Errorf("tool union must have a defined type") +} + +type ( + // ToolChoice represents the tool choice for the model. + // https://platform.claude.com/docs/en/api/messages#body-tool-choice + ToolChoice struct { + Auto *ToolChoiceAuto + Any *ToolChoiceAny + Tool *ToolChoiceTool + None *ToolChoiceNone + } + + // ToolChoiceAuto lets the model automatically decide whether to use tools. + // https://platform.claude.com/docs/en/api/messages#tool_choice_auto + ToolChoiceAuto struct { + Type string `json:"type"` // Always "auto". + DisableParallelToolUse *bool `json:"disable_parallel_tool_use,omitempty"` + } + + // ToolChoiceAny forces the model to use any available tool. + // https://platform.claude.com/docs/en/api/messages#tool_choice_any + ToolChoiceAny struct { + Type string `json:"type"` // Always "any". + DisableParallelToolUse *bool `json:"disable_parallel_tool_use,omitempty"` + } + + // ToolChoiceTool forces the model to use the specified tool. + // https://platform.claude.com/docs/en/api/messages#tool_choice_tool + ToolChoiceTool struct { + Type string `json:"type"` // Always "tool". + Name string `json:"name"` + DisableParallelToolUse *bool `json:"disable_parallel_tool_use,omitempty"` + } + + // ToolChoiceNone prevents the model from using any tools. + // https://platform.claude.com/docs/en/api/messages#tool_choice_none + ToolChoiceNone struct { + Type string `json:"type"` // Always "none". + } +) + +// Tool choice type constants used by ToolChoice. +const ( + toolChoiceTypeAuto = "auto" + toolChoiceTypeAny = "any" + toolChoiceTypeTool = "tool" + toolChoiceTypeNone = "none" +) + +func (tc *ToolChoice) UnmarshalJSON(data []byte) error { + typ := gjson.GetBytes(data, "type") + if !typ.Exists() { + return errors.New("missing type field in tool choice") + } + switch typ.String() { + case toolChoiceTypeAuto: + var toolChoice ToolChoiceAuto + if err := json.Unmarshal(data, &toolChoice); err != nil { + return fmt.Errorf("failed to unmarshal tool choice auto: %w", err) + } + tc.Auto = &toolChoice + case toolChoiceTypeAny: + var toolChoice ToolChoiceAny + if err := json.Unmarshal(data, &toolChoice); err != nil { + return fmt.Errorf("failed to unmarshal tool choice any: %w", err) + } + tc.Any = &toolChoice + case toolChoiceTypeTool: + var toolChoice ToolChoiceTool + if err := json.Unmarshal(data, &toolChoice); err != nil { + return fmt.Errorf("failed to unmarshal tool choice tool: %w", err) + } + tc.Tool = &toolChoice + case toolChoiceTypeNone: + var toolChoice ToolChoiceNone + if err := json.Unmarshal(data, &toolChoice); err != nil { + return fmt.Errorf("failed to unmarshal tool choice none: %w", err) + } + tc.None = &toolChoice default: - // TODO add others when we need it for observability, etc. - // Fow now, we ignore undefined types. + // Ignore unknown types for forward compatibility. return nil } + return nil } -// ToolChoice represents the tool choice for the model. -// https://docs.claude.com/en/api/messages#body-tool-choice -type ToolChoice any // TODO when we need it for observability, etc. +func (tc *ToolChoice) MarshalJSON() ([]byte, error) { + if tc.Auto != nil { + return json.Marshal(tc.Auto) + } + if tc.Any != nil { + return json.Marshal(tc.Any) + } + if tc.Tool != nil { + return json.Marshal(tc.Tool) + } + if tc.None != nil { + return json.Marshal(tc.None) + } + return nil, fmt.Errorf("tool choice must have a defined type") +} + +type ( + // Thinking represents the configuration for the model's "thinking" behavior. + // This is not to be confused with the thinking block that is part of the response message's contentblock + // https://platform.claude.com/docs/en/api/messages#body-thinking + Thinking struct { + Enabled *ThinkingEnabled + Disabled *ThinkingDisabled + Adaptive *ThinkingAdaptive + } + + // ThinkingEnabled enables extended thinking with a token budget. + // https://platform.claude.com/docs/en/api/messages#thinking_config_enabled + ThinkingEnabled struct { + Type string `json:"type"` // Always "enabled". + BudgetTokens float64 `json:"budget_tokens"` // Must be >= 1024 and < max_tokens. + } + + // ThinkingDisabled disables extended thinking. + // https://platform.claude.com/docs/en/api/messages#thinking_config_disabled + ThinkingDisabled struct { + Type string `json:"type"` // Always "disabled". + } + + // ThinkingAdaptive lets the model decide whether to use extended thinking. + // https://platform.claude.com/docs/en/api/messages#thinking_config_adaptive + ThinkingAdaptive struct { + Type string `json:"type"` // Always "adaptive". + } +) + +// Thinking config type constants used by Thinking. +const ( + thinkingConfigTypeEnabled = "enabled" + thinkingConfigTypeDisabled = "disabled" + thinkingConfigTypeAdaptive = "adaptive" +) + +func (t *Thinking) UnmarshalJSON(data []byte) error { + typ := gjson.GetBytes(data, "type") + if !typ.Exists() { + return errors.New("missing type field in thinking config") + } + switch typ.String() { + case thinkingConfigTypeEnabled: + var thinking ThinkingEnabled + if err := json.Unmarshal(data, &thinking); err != nil { + return fmt.Errorf("failed to unmarshal thinking enabled: %w", err) + } + t.Enabled = &thinking + case thinkingConfigTypeDisabled: + var thinking ThinkingDisabled + if err := json.Unmarshal(data, &thinking); err != nil { + return fmt.Errorf("failed to unmarshal thinking disabled: %w", err) + } + t.Disabled = &thinking + case thinkingConfigTypeAdaptive: + var thinking ThinkingAdaptive + if err := json.Unmarshal(data, &thinking); err != nil { + return fmt.Errorf("failed to unmarshal thinking adaptive: %w", err) + } + t.Adaptive = &thinking + default: + // Ignore unknown types for forward compatibility. + return nil + } + return nil +} -// Thinking represents the configuration for the model's "thinking" behavior. -// https://docs.claude.com/en/api/messages#body-thinking -type Thinking any // TODO when we need it for observability, etc. +func (t *Thinking) MarshalJSON() ([]byte, error) { + if t.Enabled != nil { + return json.Marshal(t.Enabled) + } + if t.Disabled != nil { + return json.Marshal(t.Disabled) + } + if t.Adaptive != nil { + return json.Marshal(t.Adaptive) + } + return nil, fmt.Errorf("thinking config must have a defined type") +} // SystemPrompt represents a system prompt to guide the model's behavior. // https://docs.claude.com/en/api/messages#body-system @@ -291,11 +1398,13 @@ func (s *SystemPrompt) MarshalJSON() ([]byte, error) { } // MCPServer represents an MCP server. -// https://docs.claude.com/en/api/messages#body-mcp-servers +// This became a beta status so it is not implemented for now. +// https://platform.claude.com/docs/en/api/beta/messages/create type MCPServer any // TODO when we need it for observability, etc. // ContextManagement represents the context management configuration. -// https://docs.claude.com/en/api/messages#body-context-management +// This became a beta status so it is not implemented for now. +// https://platform.claude.com/docs/en/api/beta/messages/create type ContextManagement any // TODO when we need it for observability, etc. // MessagesResponse represents a response from the Anthropic Messages API. @@ -339,20 +1448,26 @@ type ConstantMessagesResponseRoleAssistant string type ( // MessagesContentBlock represents a block of content in the Anthropic Messages API response. - // https://docs.claude.com/en/api/messages#response-content + // https://platform.claude.com/docs/en/api/messages#response-content MessagesContentBlock struct { - Text *TextBlock - Tool *ToolUseBlock - Thinking *ThinkingBlock - // TODO when we need it for observability, etc. + Text *TextBlock + Tool *ToolUseBlock + Thinking *ThinkingBlock + RedactedThinking *RedactedThinkingBlock + ServerToolUse *ServerToolUseBlock + WebSearchToolResult *WebSearchToolResultBlock } + // TextBlock represents a text content block in the response. + // https://platform.claude.com/docs/en/api/messages#text_block TextBlock struct { - Type string `json:"type"` // Always "text". - Text string `json:"text"` - // TODO: citation? + Type string `json:"type"` // Always "text". + Text string `json:"text"` + Citations []TextCitation `json:"citations,omitempty"` } + // ToolUseBlock represents a tool use content block in the response. + // https://platform.claude.com/docs/en/api/messages#tool_use_block ToolUseBlock struct { Type string `json:"type"` // Always "tool_use". ID string `json:"id"` @@ -360,11 +1475,37 @@ type ( Input map[string]any `json:"input"` } + // ThinkingBlock represents a thinking content block in the response. + // https://platform.claude.com/docs/en/api/messages#thinking_block ThinkingBlock struct { Type string `json:"type"` // Always "thinking". Thinking string `json:"thinking"` Signature string `json:"signature,omitempty"` } + + // RedactedThinkingBlock represents a redacted thinking content block in the response. + // https://platform.claude.com/docs/en/api/messages#redacted_thinking_block + RedactedThinkingBlock struct { + Type string `json:"type"` // Always "redacted_thinking". + Data string `json:"data"` + } + + // ServerToolUseBlock represents a server tool use content block in the response. + // https://platform.claude.com/docs/en/api/messages#server_tool_use_block + ServerToolUseBlock struct { + Type string `json:"type"` // Always "server_tool_use". + ID string `json:"id"` + Name string `json:"name"` // e.g. "web_search". + Input map[string]any `json:"input"` + } + + // WebSearchToolResultBlock represents a web search tool result content block in the response. + // https://platform.claude.com/docs/en/api/messages#web_search_tool_result_block + WebSearchToolResultBlock struct { + Type string `json:"type"` // Always "web_search_tool_result". + ToolUseID string `json:"tool_use_id"` + Content WebSearchToolResultContent `json:"content"` // Array of WebSearchResult or a WebSearchToolResultError. + } ) func (m *MessagesContentBlock) UnmarshalJSON(data []byte) error { @@ -373,32 +1514,47 @@ func (m *MessagesContentBlock) UnmarshalJSON(data []byte) error { return errors.New("missing type field in message content block") } switch typ.String() { - case "text": - var textBlock TextBlock - if err := json.Unmarshal(data, &textBlock); err != nil { + case contentBlockTypeText: + var contentBlock TextBlock + if err := json.Unmarshal(data, &contentBlock); err != nil { return fmt.Errorf("failed to unmarshal text block: %w", err) } - m.Text = &textBlock - return nil - case "tool_use": - var toolUseBlock ToolUseBlock - if err := json.Unmarshal(data, &toolUseBlock); err != nil { + m.Text = &contentBlock + case contentBlockTypeToolUse: + var contentBlock ToolUseBlock + if err := json.Unmarshal(data, &contentBlock); err != nil { return fmt.Errorf("failed to unmarshal tool use block: %w", err) } - m.Tool = &toolUseBlock - return nil - case "thinking": - var thinkingBlock ThinkingBlock - if err := json.Unmarshal(data, &thinkingBlock); err != nil { + m.Tool = &contentBlock + case contentBlockTypeThinking: + var contentBlock ThinkingBlock + if err := json.Unmarshal(data, &contentBlock); err != nil { return fmt.Errorf("failed to unmarshal thinking block: %w", err) } - m.Thinking = &thinkingBlock - return nil + m.Thinking = &contentBlock + case contentBlockTypeRedactedThinking: + var contentBlock RedactedThinkingBlock + if err := json.Unmarshal(data, &contentBlock); err != nil { + return fmt.Errorf("failed to unmarshal redacted thinking block: %w", err) + } + m.RedactedThinking = &contentBlock + case contentBlockTypeServerToolUse: + var contentBlock ServerToolUseBlock + if err := json.Unmarshal(data, &contentBlock); err != nil { + return fmt.Errorf("failed to unmarshal server tool use block: %w", err) + } + m.ServerToolUse = &contentBlock + case contentBlockTypeWebSearchToolResult: + var contentBlock WebSearchToolResultBlock + if err := json.Unmarshal(data, &contentBlock); err != nil { + return fmt.Errorf("failed to unmarshal web search tool result block: %w", err) + } + m.WebSearchToolResult = &contentBlock default: - // TODO add others when we need it for observability, etc. - // Fow now, we ignore undefined types. + // Ignore unknown types for forward compatibility. return nil } + return nil } func (m *MessagesContentBlock) MarshalJSON() ([]byte, error) { @@ -411,7 +1567,15 @@ func (m *MessagesContentBlock) MarshalJSON() ([]byte, error) { if m.Thinking != nil { return json.Marshal(m.Thinking) } - // TODO add others when we need it for observability, etc. + if m.RedactedThinking != nil { + return json.Marshal(m.RedactedThinking) + } + if m.ServerToolUse != nil { + return json.Marshal(m.ServerToolUse) + } + if m.WebSearchToolResult != nil { + return json.Marshal(m.WebSearchToolResult) + } return nil, fmt.Errorf("content block must have a defined type") } diff --git a/internal/apischema/anthropic/anthropic_test.go b/internal/apischema/anthropic/anthropic_test.go index 9c85fcbc4c..2c78f0eb12 100644 --- a/internal/apischema/anthropic/anthropic_test.go +++ b/internal/apischema/anthropic/anthropic_test.go @@ -9,6 +9,8 @@ import ( "testing" "github.com/stretchr/testify/require" + + "github.com/envoyproxy/ai-gateway/internal/json" ) func TestMessageContent_UnmarshalJSON(t *testing.T) { @@ -276,6 +278,93 @@ func TestContentBlockParam_UnmarshalJSON(t *testing.T) { jsonStr: `{"type": "text", "text": "Hello"}`, want: ContentBlockParam{Text: &TextBlockParam{Text: "Hello", Type: "text"}}, }, + { + name: "image block", + jsonStr: `{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "abc123"}}`, + want: ContentBlockParam{Image: &ImageBlockParam{ + Type: "image", + Source: ImageSource{Base64: &Base64ImageSource{Type: "base64", MediaType: "image/png", Data: "abc123"}}, + }}, + }, + { + name: "document block", + jsonStr: `{"type": "document", "source": {"type": "text", "data": "hello", "media_type": "text/plain"}, "context": "some context", "title": "doc title"}`, + want: ContentBlockParam{Document: &DocumentBlockParam{ + Type: "document", + Source: DocumentSource{PlainText: &PlainTextSource{Type: "text", MediaType: "text/plain", Data: "hello"}}, + Context: "some context", + Title: "doc title", + }}, + }, + { + name: "search result block", + jsonStr: `{"type": "search_result", "source": "https://example.com", "title": "Example", "content": [{"type": "text", "text": "result text"}]}`, + want: ContentBlockParam{SearchResult: &SearchResultBlockParam{ + Type: "search_result", + Source: "https://example.com", + Title: "Example", + Content: []TextBlockParam{{Type: "text", Text: "result text"}}, + }}, + }, + { + name: "thinking block", + jsonStr: `{"type": "thinking", "thinking": "Let me think...", "signature": "sig123"}`, + want: ContentBlockParam{Thinking: &ThinkingBlockParam{ + Type: "thinking", + Thinking: "Let me think...", + Signature: "sig123", + }}, + }, + { + name: "redacted thinking block", + jsonStr: `{"type": "redacted_thinking", "data": "redacted_data_here"}`, + want: ContentBlockParam{RedactedThinking: &RedactedThinkingBlockParam{ + Type: "redacted_thinking", + Data: "redacted_data_here", + }}, + }, + { + name: "tool use block", + jsonStr: `{"type": "tool_use", "id": "tu_123", "name": "my_tool", "input": {"query": "test"}}`, + want: ContentBlockParam{ToolUse: &ToolUseBlockParam{ + Type: "tool_use", + ID: "tu_123", + Name: "my_tool", + Input: map[string]any{"query": "test"}, + }}, + }, + { + name: "tool result block", + jsonStr: `{"type": "tool_result", "tool_use_id": "tu_123", "content": "result text", "is_error": false}`, + want: ContentBlockParam{ToolResult: &ToolResultBlockParam{ + Type: "tool_result", + ToolUseID: "tu_123", + Content: &ToolResultContent{Text: "result text"}, + }}, + }, + { + name: "server tool use block", + jsonStr: `{"type": "server_tool_use", "id": "stu_123", "name": "web_search", "input": {"query": "test"}}`, + want: ContentBlockParam{ServerToolUse: &ServerToolUseBlockParam{ + Type: "server_tool_use", + ID: "stu_123", + Name: "web_search", + Input: map[string]any{"query": "test"}, + }}, + }, + { + name: "web search tool result block", + jsonStr: `{"type": "web_search_tool_result", "tool_use_id": "stu_123", "content": [{"type": "web_search_result", "title": "Result", "url": "https://example.com", "encrypted_content": "enc123"}]}`, + want: ContentBlockParam{WebSearchToolResult: &WebSearchToolResultBlockParam{ + Type: "web_search_tool_result", + ToolUseID: "stu_123", + Content: WebSearchToolResultContent{ + Results: []WebSearchResult{ + {Type: "web_search_result", Title: "Result", URL: "https://example.com", EncryptedContent: "enc123"}, + }, + }, + }}, + }, { name: "missing type", jsonStr: `{"text": "Hello"}`, @@ -315,63 +404,108 @@ func TestContentBlockParam_MarshalJSON(t *testing.T) { want: `{"text":"Hello","type":"text"}`, }, { - name: "empty block", - cbp: ContentBlockParam{}, - wantErr: true, + name: "image block", + cbp: ContentBlockParam{Image: &ImageBlockParam{ + Type: "image", + Source: ImageSource{Base64: &Base64ImageSource{Type: "base64", MediaType: "image/png", Data: "abc123"}}, + }}, + want: `{"type":"image","source":{"type":"base64","media_type":"image/png","data":"abc123"}}`, }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := tt.cbp.MarshalJSON() - if tt.wantErr { - require.Error(t, err) - return - } - require.NoError(t, err) - require.JSONEq(t, tt.want, string(got)) - }) - } -} - -func TestToolUnion_UnmarshalJSON(t *testing.T) { - tests := []struct { - name string - jsonStr string - want ToolUnion - wantErr bool - }{ { - name: "custom tool", - jsonStr: `{"type": "custom", "name": "my_tool", "input_schema": {"type": "object"}}`, - want: ToolUnion{Tool: &Tool{ - Type: "custom", - Name: "my_tool", - InputSchema: ToolInputSchema{Type: "object"}, + name: "document block", + cbp: ContentBlockParam{Document: &DocumentBlockParam{ + Type: "document", + Source: DocumentSource{PlainText: &PlainTextSource{Type: "text", MediaType: "text/plain", Data: "hello"}}, + Context: "some context", + Title: "doc title", }}, + want: `{"type":"document","source":{"type":"text","data":"hello","media_type":"text/plain"},"context":"some context","title":"doc title"}`, }, { - name: "missing type", - jsonStr: `{"name": "my_tool"}`, - wantErr: true, + name: "search result block", + cbp: ContentBlockParam{SearchResult: &SearchResultBlockParam{ + Type: "search_result", + Source: "https://example.com", + Title: "Example", + Content: []TextBlockParam{{Type: "text", Text: "result text"}}, + }}, + want: `{"type":"search_result","content":[{"type":"text","text":"result text"}],"source":"https://example.com","title":"Example"}`, }, { - name: "unknown type", - jsonStr: `{"type": "unknown", "name": "my_tool"}`, - want: ToolUnion{}, + name: "thinking block", + cbp: ContentBlockParam{Thinking: &ThinkingBlockParam{ + Type: "thinking", + Thinking: "Let me think...", + Signature: "sig123", + }}, + want: `{"type":"thinking","thinking":"Let me think...","signature":"sig123"}`, + }, + { + name: "redacted thinking block", + cbp: ContentBlockParam{RedactedThinking: &RedactedThinkingBlockParam{ + Type: "redacted_thinking", + Data: "redacted_data_here", + }}, + want: `{"type":"redacted_thinking","data":"redacted_data_here"}`, + }, + { + name: "tool use block", + cbp: ContentBlockParam{ToolUse: &ToolUseBlockParam{ + Type: "tool_use", + ID: "tu_123", + Name: "my_tool", + Input: map[string]any{"query": "test"}, + }}, + want: `{"type":"tool_use","id":"tu_123","name":"my_tool","input":{"query":"test"}}`, + }, + { + name: "tool result block", + cbp: ContentBlockParam{ToolResult: &ToolResultBlockParam{ + Type: "tool_result", + ToolUseID: "tu_123", + Content: &ToolResultContent{Text: "result text"}, + }}, + want: `{"type":"tool_result","tool_use_id":"tu_123","content":"result text"}`, + }, + { + name: "server tool use block", + cbp: ContentBlockParam{ServerToolUse: &ServerToolUseBlockParam{ + Type: "server_tool_use", + ID: "stu_123", + Name: "web_search", + Input: map[string]any{"query": "test"}, + }}, + want: `{"type":"server_tool_use","id":"stu_123","name":"web_search","input":{"query":"test"}}`, + }, + { + name: "web search tool result block", + cbp: ContentBlockParam{WebSearchToolResult: &WebSearchToolResultBlockParam{ + Type: "web_search_tool_result", + ToolUseID: "stu_123", + Content: WebSearchToolResultContent{ + Results: []WebSearchResult{ + {Type: "web_search_result", Title: "Example", URL: "https://example.com", EncryptedContent: "enc123"}, + }, + }, + }}, + want: `{"type":"web_search_tool_result","tool_use_id":"stu_123","content":[{"type":"web_search_result","title":"Example","url":"https://example.com","encrypted_content":"enc123"}]}`, + }, + { + name: "empty block", + cbp: ContentBlockParam{}, + wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - var tu ToolUnion - err := tu.UnmarshalJSON([]byte(tt.jsonStr)) + got, err := tt.cbp.MarshalJSON() if tt.wantErr { require.Error(t, err) return } require.NoError(t, err) - require.Equal(t, tt.want, tu) + require.JSONEq(t, tt.want, string(got)) }) } } @@ -483,7 +617,7 @@ func TestMessagesContentBlock_UnmarshalJSON(t *testing.T) { want: MessagesContentBlock{Tool: &ToolUseBlock{ Type: "tool_use", Name: "my_tool", - Input: map[string]interface{}{ + Input: map[string]any{ "query": "What is the weather today?", }, }}, @@ -496,6 +630,37 @@ func TestMessagesContentBlock_UnmarshalJSON(t *testing.T) { Thinking: "Let me think about that.", }}, }, + { + name: "redacted thinking block", + jsonStr: `{"type": "redacted_thinking", "data": "redacted_data"}`, + want: MessagesContentBlock{RedactedThinking: &RedactedThinkingBlock{ + Type: "redacted_thinking", + Data: "redacted_data", + }}, + }, + { + name: "server tool use block", + jsonStr: `{"type": "server_tool_use", "id": "stu_1", "name": "web_search", "input": {"query": "test"}}`, + want: MessagesContentBlock{ServerToolUse: &ServerToolUseBlock{ + Type: "server_tool_use", + ID: "stu_1", + Name: "web_search", + Input: map[string]any{"query": "test"}, + }}, + }, + { + name: "web search tool result block", + jsonStr: `{"type": "web_search_tool_result", "tool_use_id": "stu_1", "content": [{"type": "web_search_result", "title": "Result", "url": "https://example.com", "encrypted_content": "enc456"}]}`, + want: MessagesContentBlock{WebSearchToolResult: &WebSearchToolResultBlock{ + Type: "web_search_tool_result", + ToolUseID: "stu_1", + Content: WebSearchToolResultContent{ + Results: []WebSearchResult{ + {Type: "web_search_result", Title: "Result", URL: "https://example.com", EncryptedContent: "enc456"}, + }, + }, + }}, + }, } for _, tt := range tests { @@ -520,3 +685,1715 @@ func TestMessagesContentBlock_UnmarshalJSON(t *testing.T) { }) } } + +func TestMessagesContentBlock_MarshalJSON(t *testing.T) { + t.Run("empty block returns error", func(t *testing.T) { + mcb := MessagesContentBlock{} + _, err := mcb.MarshalJSON() + require.Error(t, err) + }) +} + +func TestToolUnion_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + jsonStr string + want ToolUnion + wantErr bool + }{ + { + name: "custom tool", + jsonStr: `{"type":"custom","name":"my_tool","input_schema":{"type":"object"}}`, + want: ToolUnion{Tool: &Tool{ + Type: "custom", Name: "my_tool", + InputSchema: ToolInputSchema{Type: "object"}, + }}, + }, + { + name: "bash tool", + jsonStr: `{"type":"bash_20250124","name":"bash"}`, + want: ToolUnion{BashTool: &BashTool{Type: "bash_20250124", Name: "bash"}}, + }, + { + name: "text editor tool 20250124", + jsonStr: `{"type":"text_editor_20250124","name":"str_replace_editor"}`, + want: ToolUnion{TextEditorTool20250124: &TextEditorTool20250124{Type: "text_editor_20250124", Name: "str_replace_editor"}}, + }, + { + name: "text editor tool 20250429", + jsonStr: `{"type":"text_editor_20250429","name":"str_replace_based_edit_tool"}`, + want: ToolUnion{TextEditorTool20250429: &TextEditorTool20250429{Type: "text_editor_20250429", Name: "str_replace_based_edit_tool"}}, + }, + { + name: "text editor tool 20250728", + jsonStr: `{"type":"text_editor_20250728","name":"str_replace_based_edit_tool"}`, + want: ToolUnion{TextEditorTool20250728: &TextEditorTool20250728{Type: "text_editor_20250728", Name: "str_replace_based_edit_tool"}}, + }, + { + name: "web search tool", + jsonStr: `{"type":"web_search_20250305","name":"web_search"}`, + want: ToolUnion{WebSearchTool: &WebSearchTool{Type: "web_search_20250305", Name: "web_search"}}, + }, + { + name: "missing type", + jsonStr: `{"name":"my_tool"}`, + wantErr: true, + }, + { + name: "unknown type ignored", + jsonStr: `{"type":"future_tool","name":"x"}`, + want: ToolUnion{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var tu ToolUnion + err := tu.UnmarshalJSON([]byte(tt.jsonStr)) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tt.want, tu) + }) + } +} + +func TestToolUnion_MarshalJSON(t *testing.T) { + tests := []struct { + name string + tu ToolUnion + want string + wantErr bool + }{ + { + name: "custom tool", + tu: ToolUnion{Tool: &Tool{Type: "custom", Name: "t", InputSchema: ToolInputSchema{Type: "object"}}}, + want: `{"type":"custom","name":"t","input_schema":{"type":"object"}}`, + }, + { + name: "bash tool", + tu: ToolUnion{BashTool: &BashTool{Type: "bash_20250124", Name: "bash"}}, + want: `{"type":"bash_20250124","name":"bash"}`, + }, + { + name: "text editor 20250124", + tu: ToolUnion{TextEditorTool20250124: &TextEditorTool20250124{Type: "text_editor_20250124", Name: "str_replace_editor"}}, + want: `{"type":"text_editor_20250124","name":"str_replace_editor"}`, + }, + { + name: "text editor 20250429", + tu: ToolUnion{TextEditorTool20250429: &TextEditorTool20250429{Type: "text_editor_20250429", Name: "n"}}, + want: `{"type":"text_editor_20250429","name":"n"}`, + }, + { + name: "text editor 20250728", + tu: ToolUnion{TextEditorTool20250728: &TextEditorTool20250728{Type: "text_editor_20250728", Name: "n"}}, + want: `{"type":"text_editor_20250728","name":"n"}`, + }, + { + name: "web search tool", + tu: ToolUnion{WebSearchTool: &WebSearchTool{Type: "web_search_20250305", Name: "web_search"}}, + want: `{"type":"web_search_20250305","name":"web_search"}`, + }, + { + name: "empty tool union", + tu: ToolUnion{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.tu.MarshalJSON() + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.JSONEq(t, tt.want, string(got)) + }) + } +} + +func TestToolChoice_UnmarshalJSON(t *testing.T) { + boolTrue := true + tests := []struct { + name string + jsonStr string + want ToolChoice + wantErr bool + }{ + { + name: "auto", + jsonStr: `{"type":"auto","disable_parallel_tool_use":true}`, + want: ToolChoice{Auto: &ToolChoiceAuto{Type: "auto", DisableParallelToolUse: &boolTrue}}, + }, + { + name: "any", + jsonStr: `{"type":"any"}`, + want: ToolChoice{Any: &ToolChoiceAny{Type: "any"}}, + }, + { + name: "tool", + jsonStr: `{"type":"tool","name":"my_tool"}`, + want: ToolChoice{Tool: &ToolChoiceTool{Type: "tool", Name: "my_tool"}}, + }, + { + name: "none", + jsonStr: `{"type":"none"}`, + want: ToolChoice{None: &ToolChoiceNone{Type: "none"}}, + }, + { + name: "missing type", + jsonStr: `{"name":"x"}`, + wantErr: true, + }, + { + name: "unknown type ignored", + jsonStr: `{"type":"future"}`, + want: ToolChoice{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var tc ToolChoice + err := tc.UnmarshalJSON([]byte(tt.jsonStr)) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tt.want, tc) + }) + } +} + +func TestToolChoice_MarshalJSON(t *testing.T) { + tests := []struct { + name string + tc ToolChoice + want string + wantErr bool + }{ + { + name: "auto", + tc: ToolChoice{Auto: &ToolChoiceAuto{Type: "auto"}}, + want: `{"type":"auto"}`, + }, + { + name: "any", + tc: ToolChoice{Any: &ToolChoiceAny{Type: "any"}}, + want: `{"type":"any"}`, + }, + { + name: "tool", + tc: ToolChoice{Tool: &ToolChoiceTool{Type: "tool", Name: "my_tool"}}, + want: `{"type":"tool","name":"my_tool"}`, + }, + { + name: "none", + tc: ToolChoice{None: &ToolChoiceNone{Type: "none"}}, + want: `{"type":"none"}`, + }, + { + name: "empty tool choice", + tc: ToolChoice{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.tc.MarshalJSON() + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.JSONEq(t, tt.want, string(got)) + }) + } +} + +func TestThinking_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + jsonStr string + want Thinking + wantErr bool + }{ + { + name: "enabled", + jsonStr: `{"type":"enabled","budget_tokens":2048}`, + want: Thinking{Enabled: &ThinkingEnabled{Type: "enabled", BudgetTokens: 2048}}, + }, + { + name: "disabled", + jsonStr: `{"type":"disabled"}`, + want: Thinking{Disabled: &ThinkingDisabled{Type: "disabled"}}, + }, + { + name: "adaptive", + jsonStr: `{"type":"adaptive"}`, + want: Thinking{Adaptive: &ThinkingAdaptive{Type: "adaptive"}}, + }, + { + name: "missing type", + jsonStr: `{"budget_tokens":1024}`, + wantErr: true, + }, + { + name: "unknown type ignored", + jsonStr: `{"type":"future"}`, + want: Thinking{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var th Thinking + err := th.UnmarshalJSON([]byte(tt.jsonStr)) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tt.want, th) + }) + } +} + +func TestThinking_MarshalJSON(t *testing.T) { + tests := []struct { + name string + th Thinking + want string + wantErr bool + }{ + { + name: "enabled", + th: Thinking{Enabled: &ThinkingEnabled{Type: "enabled", BudgetTokens: 2048}}, + want: `{"type":"enabled","budget_tokens":2048}`, + }, + { + name: "disabled", + th: Thinking{Disabled: &ThinkingDisabled{Type: "disabled"}}, + want: `{"type":"disabled"}`, + }, + { + name: "adaptive", + th: Thinking{Adaptive: &ThinkingAdaptive{Type: "adaptive"}}, + want: `{"type":"adaptive"}`, + }, + { + name: "empty thinking", + th: Thinking{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.th.MarshalJSON() + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.JSONEq(t, tt.want, string(got)) + }) + } +} + +func TestContentBlockParam_UnmarshalJSON_ErrorPaths(t *testing.T) { + // Each case has a valid "type" but invalid JSON for that type's struct, + // triggering the unmarshal error path for each content block variant. + tests := []struct { + name string + jsonStr string + }{ + {name: "text invalid", jsonStr: `{"type":"text","text":123}`}, + {name: "image invalid", jsonStr: `{"type":"image","source":null,"cache_control":}`}, + {name: "document invalid", jsonStr: `{"type":"document","source":null,"context":123}`}, + {name: "search_result invalid", jsonStr: `{"type":"search_result","content":"not_array"}`}, + {name: "thinking invalid", jsonStr: `{"type":"thinking","thinking":123}`}, + {name: "redacted_thinking invalid", jsonStr: `{"type":"redacted_thinking","data":123}`}, + {name: "tool_use invalid", jsonStr: `{"type":"tool_use","input":"not_object"}`}, + {name: "tool_result invalid", jsonStr: `{"type":"tool_result","is_error":"not_bool"}`}, + {name: "server_tool_use invalid", jsonStr: `{"type":"server_tool_use","input":"not_object"}`}, + {name: "web_search_tool_result invalid", jsonStr: `{"type":"web_search_tool_result","tool_use_id":123}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var cbp ContentBlockParam + err := cbp.UnmarshalJSON([]byte(tt.jsonStr)) + require.Error(t, err) + }) + } +} + +func TestMessagesContentBlock_UnmarshalJSON_ErrorPaths(t *testing.T) { + tests := []struct { + name string + jsonStr string + }{ + {name: "tool_use invalid", jsonStr: `{"type":"tool_use","input":"bad"}`}, + {name: "thinking invalid", jsonStr: `{"type":"thinking","thinking":123}`}, + {name: "redacted_thinking invalid", jsonStr: `{"type":"redacted_thinking","data":123}`}, + {name: "server_tool_use invalid", jsonStr: `{"type":"server_tool_use","input":"bad"}`}, + {name: "web_search_tool_result invalid", jsonStr: `{"type":"web_search_tool_result","tool_use_id":123}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var mcb MessagesContentBlock + err := mcb.UnmarshalJSON([]byte(tt.jsonStr)) + require.Error(t, err) + }) + } +} + +func TestToolUnion_UnmarshalJSON_ErrorPaths(t *testing.T) { + tests := []struct { + name string + jsonStr string + }{ + {name: "custom invalid", jsonStr: `{"type":"custom","input_schema":"bad"}`}, + {name: "bash invalid", jsonStr: `{"type":"bash_20250124","name":123}`}, + {name: "text_editor_20250124 invalid", jsonStr: `{"type":"text_editor_20250124","name":123}`}, + {name: "text_editor_20250429 invalid", jsonStr: `{"type":"text_editor_20250429","name":123}`}, + {name: "text_editor_20250728 invalid", jsonStr: `{"type":"text_editor_20250728","name":123}`}, + {name: "web_search invalid", jsonStr: `{"type":"web_search_20250305","name":123}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var tu ToolUnion + err := tu.UnmarshalJSON([]byte(tt.jsonStr)) + require.Error(t, err) + }) + } +} + +func TestToolChoice_UnmarshalJSON_ErrorPaths(t *testing.T) { + tests := []struct { + name string + jsonStr string + }{ + {name: "auto invalid", jsonStr: `{"type":"auto","disable_parallel_tool_use":"bad"}`}, + {name: "any invalid", jsonStr: `{"type":"any","disable_parallel_tool_use":"bad"}`}, + {name: "tool invalid", jsonStr: `{"type":"tool","name":123}`}, + {name: "none invalid", jsonStr: `{"type":"none","type":123}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var tc ToolChoice + err := tc.UnmarshalJSON([]byte(tt.jsonStr)) + require.Error(t, err) + }) + } +} + +func TestThinking_UnmarshalJSON_ErrorPaths(t *testing.T) { + tests := []struct { + name string + jsonStr string + }{ + {name: "enabled invalid", jsonStr: `{"type":"enabled","budget_tokens":"bad"}`}, + {name: "disabled invalid", jsonStr: `{"type":"disabled","type":123}`}, + {name: "adaptive invalid", jsonStr: `{"type":"adaptive","type":123}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var th Thinking + err := th.UnmarshalJSON([]byte(tt.jsonStr)) + require.Error(t, err) + }) + } +} + +func TestMessagesStreamChunk_UnmarshalJSON_ErrorPaths(t *testing.T) { + tests := []struct { + name string + jsonStr string + }{ + {name: "message_delta invalid", jsonStr: `{"type":"message_delta","usage":"bad"}`}, + {name: "message_stop invalid", jsonStr: `{"type":"message_stop","type":123}`}, + {name: "content_block_start invalid", jsonStr: `{"type":"content_block_start","content_block":"bad"}`}, + {name: "content_block_delta invalid", jsonStr: `{"type":"content_block_delta","delta":"bad"}`}, + {name: "content_block_stop invalid", jsonStr: `{"type":"content_block_stop","index":"bad"}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var msc MessagesStreamChunk + err := msc.UnmarshalJSON([]byte(tt.jsonStr)) + require.Error(t, err) + }) + } +} + +func TestCacheControl_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + jsonStr string + want CacheControl + wantErr bool + }{ + { + name: "ephemeral no TTL", + jsonStr: `{"type":"ephemeral"}`, + want: CacheControl{Ephemeral: &CacheControlEphemeral{Type: "ephemeral"}}, + }, + { + name: "ephemeral with 5m TTL", + jsonStr: `{"type":"ephemeral","ttl":"5m"}`, + want: CacheControl{Ephemeral: &CacheControlEphemeral{ + Type: "ephemeral", + TTL: strPtr("5m"), + }}, + }, + { + name: "ephemeral with 1h TTL", + jsonStr: `{"type":"ephemeral","ttl":"1h"}`, + want: CacheControl{Ephemeral: &CacheControlEphemeral{ + Type: "ephemeral", + TTL: strPtr("1h"), + }}, + }, + { + name: "unknown type ignored", + jsonStr: `{"type":"persistent"}`, + want: CacheControl{}, + }, + { + name: "missing type", + jsonStr: `{"ttl":"5m"}`, + wantErr: true, + }, + { + name: "invalid JSON", + jsonStr: `{bad}`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var cc CacheControl + err := cc.UnmarshalJSON([]byte(tt.jsonStr)) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tt.want, cc) + }) + } +} + +func TestCacheControl_MarshalJSON(t *testing.T) { + tests := []struct { + name string + cc CacheControl + want string + wantErr bool + }{ + { + name: "ephemeral no TTL", + cc: CacheControl{Ephemeral: &CacheControlEphemeral{Type: "ephemeral"}}, + want: `{"type":"ephemeral"}`, + }, + { + name: "ephemeral with TTL", + cc: CacheControl{Ephemeral: &CacheControlEphemeral{Type: "ephemeral", TTL: strPtr("1h")}}, + want: `{"type":"ephemeral","ttl":"1h"}`, + }, + { + name: "empty cache control", + cc: CacheControl{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.cc.MarshalJSON() + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.JSONEq(t, tt.want, string(got)) + }) + } +} + +func TestImageSource_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + jsonStr string + want ImageSource + wantErr bool + }{ + { + name: "base64 jpeg", + jsonStr: `{"type":"base64","media_type":"image/jpeg","data":"abc123"}`, + want: ImageSource{Base64: &Base64ImageSource{ + Type: "base64", MediaType: "image/jpeg", Data: "abc123", + }}, + }, + { + name: "base64 png", + jsonStr: `{"type":"base64","media_type":"image/png","data":"xyz789"}`, + want: ImageSource{Base64: &Base64ImageSource{ + Type: "base64", MediaType: "image/png", Data: "xyz789", + }}, + }, + { + name: "url source", + jsonStr: `{"type":"url","url":"https://example.com/image.png"}`, + want: ImageSource{URL: &URLImageSource{ + Type: "url", URL: "https://example.com/image.png", + }}, + }, + { + name: "unknown type ignored", + jsonStr: `{"type":"file","file_id":"file_123"}`, + want: ImageSource{}, + }, + { + name: "missing type", + jsonStr: `{"media_type":"image/png","data":"abc"}`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var is ImageSource + err := is.UnmarshalJSON([]byte(tt.jsonStr)) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tt.want, is) + }) + } +} + +func TestImageSource_MarshalJSON(t *testing.T) { + tests := []struct { + name string + is ImageSource + want string + wantErr bool + }{ + { + name: "base64", + is: ImageSource{Base64: &Base64ImageSource{Type: "base64", MediaType: "image/gif", Data: "gif_data"}}, + want: `{"type":"base64","media_type":"image/gif","data":"gif_data"}`, + }, + { + name: "url", + is: ImageSource{URL: &URLImageSource{Type: "url", URL: "https://example.com/img.webp"}}, + want: `{"type":"url","url":"https://example.com/img.webp"}`, + }, + { + name: "empty image source", + is: ImageSource{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.is.MarshalJSON() + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.JSONEq(t, tt.want, string(got)) + }) + } +} + +func TestImageSource_UnmarshalJSON_ErrorPaths(t *testing.T) { + tests := []struct { + name string + jsonStr string + }{ + {name: "base64 invalid", jsonStr: `{"type":"base64","data":123}`}, + {name: "url invalid", jsonStr: `{"type":"url","url":123}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var is ImageSource + err := is.UnmarshalJSON([]byte(tt.jsonStr)) + require.Error(t, err) + }) + } +} + +func TestDocumentSource_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + jsonStr string + want DocumentSource + wantErr bool + }{ + { + name: "base64 PDF", + jsonStr: `{"type":"base64","media_type":"application/pdf","data":"pdf_data"}`, + want: DocumentSource{Base64PDF: &Base64PDFSource{ + Type: "base64", MediaType: "application/pdf", Data: "pdf_data", + }}, + }, + { + name: "plain text", + jsonStr: `{"type":"text","media_type":"text/plain","data":"hello world"}`, + want: DocumentSource{PlainText: &PlainTextSource{ + Type: "text", MediaType: "text/plain", Data: "hello world", + }}, + }, + { + name: "URL PDF", + jsonStr: `{"type":"url","url":"https://example.com/doc.pdf"}`, + want: DocumentSource{URL: &URLPDFSource{ + Type: "url", URL: "https://example.com/doc.pdf", + }}, + }, + { + name: "content block - string content", + jsonStr: `{"type":"content","content":"some text content"}`, + want: DocumentSource{ContentBlock: &ContentBlockSource{ + Type: "content", + Content: ContentBlockSourceContent{Text: "some text content"}, + }}, + }, + { + name: "content block - array content", + jsonStr: `{"type":"content","content":[{"type":"text","text":"part one"}]}`, + want: DocumentSource{ContentBlock: &ContentBlockSource{ + Type: "content", + Content: ContentBlockSourceContent{ + Array: []ContentBlockSourceItem{ + {Text: &TextBlockParam{Type: "text", Text: "part one"}}, + }, + }, + }}, + }, + { + name: "unknown type ignored", + jsonStr: `{"type":"file","file_id":"f_123"}`, + want: DocumentSource{}, + }, + { + name: "missing type", + jsonStr: `{"data":"pdf_data"}`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var ds DocumentSource + err := ds.UnmarshalJSON([]byte(tt.jsonStr)) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tt.want, ds) + }) + } +} + +func TestDocumentSource_MarshalJSON(t *testing.T) { + tests := []struct { + name string + ds DocumentSource + want string + wantErr bool + }{ + { + name: "base64 PDF", + ds: DocumentSource{Base64PDF: &Base64PDFSource{Type: "base64", MediaType: "application/pdf", Data: "pdf_data"}}, + want: `{"type":"base64","media_type":"application/pdf","data":"pdf_data"}`, + }, + { + name: "plain text", + ds: DocumentSource{PlainText: &PlainTextSource{Type: "text", MediaType: "text/plain", Data: "hello"}}, + want: `{"type":"text","media_type":"text/plain","data":"hello"}`, + }, + { + name: "URL PDF", + ds: DocumentSource{URL: &URLPDFSource{Type: "url", URL: "https://example.com/doc.pdf"}}, + want: `{"type":"url","url":"https://example.com/doc.pdf"}`, + }, + { + name: "content block with string", + ds: DocumentSource{ContentBlock: &ContentBlockSource{ + Type: "content", + Content: ContentBlockSourceContent{Text: "text content"}, + }}, + want: `{"type":"content","content":"text content"}`, + }, + { + name: "content block with array", + ds: DocumentSource{ContentBlock: &ContentBlockSource{ + Type: "content", + Content: ContentBlockSourceContent{ + Array: []ContentBlockSourceItem{ + {Text: &TextBlockParam{Type: "text", Text: "hello"}}, + }, + }, + }}, + want: `{"type":"content","content":[{"text":"hello","type":"text"}]}`, + }, + { + name: "empty document source", + ds: DocumentSource{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.ds.MarshalJSON() + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.JSONEq(t, tt.want, string(got)) + }) + } +} + +func TestDocumentSource_UnmarshalJSON_ErrorPaths(t *testing.T) { + tests := []struct { + name string + jsonStr string + }{ + {name: "base64 invalid", jsonStr: `{"type":"base64","data":123}`}, + {name: "text invalid", jsonStr: `{"type":"text","data":123}`}, + {name: "url invalid", jsonStr: `{"type":"url","url":123}`}, + {name: "content invalid", jsonStr: `{"type":"content","content":123}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var ds DocumentSource + err := ds.UnmarshalJSON([]byte(tt.jsonStr)) + require.Error(t, err) + }) + } +} + +func TestContentBlockSourceContent_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + jsonStr string + want ContentBlockSourceContent + wantErr bool + }{ + { + name: "string content", + jsonStr: `"hello world"`, + want: ContentBlockSourceContent{Text: "hello world"}, + }, + { + name: "array with text block", + jsonStr: `[{"type":"text","text":"block one"}]`, + want: ContentBlockSourceContent{ + Array: []ContentBlockSourceItem{ + {Text: &TextBlockParam{Type: "text", Text: "block one"}}, + }, + }, + }, + { + name: "array with image block", + jsonStr: `[{"type":"image","source":{"type":"url","url":"https://example.com/img.png"}}]`, + want: ContentBlockSourceContent{ + Array: []ContentBlockSourceItem{ + {Image: &ImageBlockParam{ + Type: "image", + Source: ImageSource{URL: &URLImageSource{Type: "url", URL: "https://example.com/img.png"}}, + }}, + }, + }, + }, + { + name: "invalid content", + jsonStr: `12345`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var c ContentBlockSourceContent + err := c.UnmarshalJSON([]byte(tt.jsonStr)) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tt.want, c) + }) + } +} + +func TestContentBlockSourceContent_MarshalJSON(t *testing.T) { + tests := []struct { + name string + c ContentBlockSourceContent + want string + wantErr bool + }{ + { + name: "string content", + c: ContentBlockSourceContent{Text: "hello world"}, + want: `"hello world"`, + }, + { + name: "array content", + c: ContentBlockSourceContent{ + Array: []ContentBlockSourceItem{ + {Text: &TextBlockParam{Type: "text", Text: "item"}}, + }, + }, + want: `[{"text":"item","type":"text"}]`, + }, + { + name: "empty content", + c: ContentBlockSourceContent{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.c.MarshalJSON() + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.JSONEq(t, tt.want, string(got)) + }) + } +} + +func TestContentBlockSourceItem_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + jsonStr string + want ContentBlockSourceItem + wantErr bool + }{ + { + name: "text item", + jsonStr: `{"type":"text","text":"hello"}`, + want: ContentBlockSourceItem{Text: &TextBlockParam{Type: "text", Text: "hello"}}, + }, + { + name: "image item", + jsonStr: `{"type":"image","source":{"type":"base64","media_type":"image/png","data":"abc"}}`, + want: ContentBlockSourceItem{Image: &ImageBlockParam{ + Type: "image", + Source: ImageSource{Base64: &Base64ImageSource{Type: "base64", MediaType: "image/png", Data: "abc"}}, + }}, + }, + { + name: "unknown type ignored", + jsonStr: `{"type":"document","source":{"type":"url","url":"https://example.com/doc.pdf"}}`, + want: ContentBlockSourceItem{}, + }, + { + name: "missing type", + jsonStr: `{"text":"hello"}`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var item ContentBlockSourceItem + err := item.UnmarshalJSON([]byte(tt.jsonStr)) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tt.want, item) + }) + } +} + +func TestContentBlockSourceItem_MarshalJSON(t *testing.T) { + tests := []struct { + name string + item ContentBlockSourceItem + want string + wantErr bool + }{ + { + name: "text item", + item: ContentBlockSourceItem{Text: &TextBlockParam{Type: "text", Text: "hello"}}, + want: `{"text":"hello","type":"text"}`, + }, + { + name: "image item", + item: ContentBlockSourceItem{Image: &ImageBlockParam{ + Type: "image", + Source: ImageSource{URL: &URLImageSource{Type: "url", URL: "https://example.com/img.png"}}, + }}, + want: `{"type":"image","source":{"type":"url","url":"https://example.com/img.png"}}`, + }, + { + name: "empty item", + item: ContentBlockSourceItem{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.item.MarshalJSON() + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.JSONEq(t, tt.want, string(got)) + }) + } +} + +func TestContentBlockSourceItem_UnmarshalJSON_ErrorPaths(t *testing.T) { + tests := []struct { + name string + jsonStr string + }{ + {name: "text invalid", jsonStr: `{"type":"text","text":123}`}, + {name: "image invalid", jsonStr: `{"type":"image","source":{"type":"url","url":123}}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var item ContentBlockSourceItem + err := item.UnmarshalJSON([]byte(tt.jsonStr)) + require.Error(t, err) + }) + } +} + +func TestTextCitation_UnmarshalJSON(t *testing.T) { + docTitle := "My Document" + tests := []struct { + name string + jsonStr string + want TextCitation + wantErr bool + }{ + { + name: "char_location", + jsonStr: `{"type":"char_location","cited_text":"exact quote","document_index":0,"document_title":"My Document","start_char_index":10,"end_char_index":21}`, + want: TextCitation{CharLocation: &CitationCharLocation{ + Type: "char_location", CitedText: "exact quote", DocumentIndex: 0, + DocumentTitle: &docTitle, StartCharIndex: 10, EndCharIndex: 21, + }}, + }, + { + name: "char_location no title", + jsonStr: `{"type":"char_location","cited_text":"quote","document_index":1,"start_char_index":5,"end_char_index":10}`, + want: TextCitation{CharLocation: &CitationCharLocation{ + Type: "char_location", CitedText: "quote", DocumentIndex: 1, + StartCharIndex: 5, EndCharIndex: 10, + }}, + }, + { + name: "page_location", + jsonStr: `{"type":"page_location","cited_text":"page text","document_index":2,"document_title":"My Document","start_page_number":3,"end_page_number":5}`, + want: TextCitation{PageLocation: &CitationPageLocation{ + Type: "page_location", CitedText: "page text", DocumentIndex: 2, + DocumentTitle: &docTitle, StartPageNumber: 3, EndPageNumber: 5, + }}, + }, + { + name: "content_block_location", + jsonStr: `{"type":"content_block_location","cited_text":"block text","document_index":0,"document_title":"My Document","start_block_index":1,"end_block_index":3}`, + want: TextCitation{ContentBlockLocation: &CitationContentBlockLocation{ + Type: "content_block_location", CitedText: "block text", DocumentIndex: 0, + DocumentTitle: &docTitle, StartBlockIndex: 1, EndBlockIndex: 3, + }}, + }, + { + name: "web_search_result_location", + jsonStr: `{"type":"web_search_result_location","cited_text":"web quote","encrypted_index":"enc_abc","title":"Example Page","url":"https://example.com"}`, + want: TextCitation{WebSearchResultLocation: &CitationWebSearchResultLocation{ + Type: "web_search_result_location", CitedText: "web quote", + EncryptedIndex: "enc_abc", Title: "Example Page", URL: "https://example.com", + }}, + }, + { + name: "search_result_location", + jsonStr: `{"type":"search_result_location","cited_text":"search text","title":"Result Title","source":"https://source.com","start_block_index":0,"end_block_index":2,"search_result_index":1}`, + want: TextCitation{SearchResultLocation: &CitationSearchResultLocation{ + Type: "search_result_location", CitedText: "search text", Title: "Result Title", + Source: "https://source.com", StartBlockIndex: 0, EndBlockIndex: 2, SearchResultIndex: 1, + }}, + }, + { + name: "unknown type ignored", + jsonStr: `{"type":"future_citation","data":"x"}`, + want: TextCitation{}, + }, + { + name: "missing type", + jsonStr: `{"cited_text":"quote"}`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var c TextCitation + err := c.UnmarshalJSON([]byte(tt.jsonStr)) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tt.want, c) + }) + } +} + +func TestTextCitation_MarshalJSON(t *testing.T) { + docTitle := "My Document" + tests := []struct { + name string + c TextCitation + want string + wantErr bool + }{ + { + name: "char_location", + c: TextCitation{CharLocation: &CitationCharLocation{ + Type: "char_location", CitedText: "quote", DocumentIndex: 0, + DocumentTitle: &docTitle, StartCharIndex: 5, EndCharIndex: 10, + }}, + want: `{"type":"char_location","cited_text":"quote","document_index":0,"document_title":"My Document","start_char_index":5,"end_char_index":10}`, + }, + { + name: "page_location", + c: TextCitation{PageLocation: &CitationPageLocation{ + Type: "page_location", CitedText: "page text", DocumentIndex: 1, + StartPageNumber: 2, EndPageNumber: 4, + }}, + want: `{"type":"page_location","cited_text":"page text","document_index":1,"start_page_number":2,"end_page_number":4}`, + }, + { + name: "content_block_location", + c: TextCitation{ContentBlockLocation: &CitationContentBlockLocation{ + Type: "content_block_location", CitedText: "block", DocumentIndex: 0, + StartBlockIndex: 0, EndBlockIndex: 1, + }}, + want: `{"type":"content_block_location","cited_text":"block","document_index":0,"start_block_index":0,"end_block_index":1}`, + }, + { + name: "web_search_result_location", + c: TextCitation{WebSearchResultLocation: &CitationWebSearchResultLocation{ + Type: "web_search_result_location", CitedText: "web text", + EncryptedIndex: "enc_xyz", URL: "https://example.com", + }}, + want: `{"type":"web_search_result_location","cited_text":"web text","encrypted_index":"enc_xyz","url":"https://example.com"}`, + }, + { + name: "search_result_location", + c: TextCitation{SearchResultLocation: &CitationSearchResultLocation{ + Type: "search_result_location", CitedText: "search text", + Source: "https://source.com", StartBlockIndex: 0, EndBlockIndex: 1, SearchResultIndex: 2, + }}, + want: `{"type":"search_result_location","cited_text":"search text","source":"https://source.com","start_block_index":0,"end_block_index":1,"search_result_index":2}`, + }, + { + name: "empty citation", + c: TextCitation{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.c.MarshalJSON() + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.JSONEq(t, tt.want, string(got)) + }) + } +} + +func TestTextCitation_UnmarshalJSON_ErrorPaths(t *testing.T) { + tests := []struct { + name string + jsonStr string + }{ + {name: "char_location invalid", jsonStr: `{"type":"char_location","document_index":"bad"}`}, + {name: "page_location invalid", jsonStr: `{"type":"page_location","document_index":"bad"}`}, + {name: "content_block_location invalid", jsonStr: `{"type":"content_block_location","document_index":"bad"}`}, + {name: "web_search_result_location invalid", jsonStr: `{"type":"web_search_result_location","cited_text":123}`}, + {name: "search_result_location invalid", jsonStr: `{"type":"search_result_location","cited_text":123}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var c TextCitation + err := c.UnmarshalJSON([]byte(tt.jsonStr)) + require.Error(t, err) + }) + } +} + +func TestWebSearchToolResultContent_UnmarshalJSON(t *testing.T) { + pageAge := "2 days ago" + tests := []struct { + name string + jsonStr string + want WebSearchToolResultContent + wantErr bool + }{ + { + name: "array of results", + jsonStr: `[{"type":"web_search_result","title":"Example","url":"https://example.com","encrypted_content":"enc123"}]`, + want: WebSearchToolResultContent{ + Results: []WebSearchResult{ + {Type: "web_search_result", Title: "Example", URL: "https://example.com", EncryptedContent: "enc123"}, + }, + }, + }, + { + name: "result with page age", + jsonStr: `[{"type":"web_search_result","title":"Old Page","url":"https://old.com","encrypted_content":"enc456","page_age":"2 days ago"}]`, + want: WebSearchToolResultContent{ + Results: []WebSearchResult{ + {Type: "web_search_result", Title: "Old Page", URL: "https://old.com", EncryptedContent: "enc456", PageAge: &pageAge}, + }, + }, + }, + { + name: "multiple results", + jsonStr: `[{"type":"web_search_result","title":"A","url":"https://a.com","encrypted_content":"enc_a"},{"type":"web_search_result","title":"B","url":"https://b.com","encrypted_content":"enc_b"}]`, + want: WebSearchToolResultContent{ + Results: []WebSearchResult{ + {Type: "web_search_result", Title: "A", URL: "https://a.com", EncryptedContent: "enc_a"}, + {Type: "web_search_result", Title: "B", URL: "https://b.com", EncryptedContent: "enc_b"}, + }, + }, + }, + { + name: "error result", + jsonStr: `{"type":"web_search_tool_result_error","error_code":"unavailable"}`, + want: WebSearchToolResultContent{ + Error: &WebSearchToolResultError{Type: "web_search_tool_result_error", ErrorCode: "unavailable"}, + }, + }, + { + name: "max_uses_exceeded error", + jsonStr: `{"type":"web_search_tool_result_error","error_code":"max_uses_exceeded"}`, + want: WebSearchToolResultContent{ + Error: &WebSearchToolResultError{Type: "web_search_tool_result_error", ErrorCode: "max_uses_exceeded"}, + }, + }, + { + name: "empty array", + jsonStr: `[]`, + want: WebSearchToolResultContent{Results: []WebSearchResult{}}, + }, + { + name: "invalid content - plain string", + jsonStr: `"some string"`, + wantErr: true, + }, + { + name: "invalid content - number", + jsonStr: `42`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var w WebSearchToolResultContent + err := w.UnmarshalJSON([]byte(tt.jsonStr)) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tt.want, w) + }) + } +} + +func TestWebSearchToolResultContent_MarshalJSON(t *testing.T) { + tests := []struct { + name string + w WebSearchToolResultContent + want string + wantErr bool + }{ + { + name: "results", + w: WebSearchToolResultContent{ + Results: []WebSearchResult{ + {Type: "web_search_result", Title: "Example", URL: "https://example.com", EncryptedContent: "enc123"}, + }, + }, + want: `[{"type":"web_search_result","title":"Example","url":"https://example.com","encrypted_content":"enc123"}]`, + }, + { + name: "error", + w: WebSearchToolResultContent{ + Error: &WebSearchToolResultError{Type: "web_search_tool_result_error", ErrorCode: "query_too_long"}, + }, + want: `{"type":"web_search_tool_result_error","error_code":"query_too_long"}`, + }, + { + name: "empty results array", + w: WebSearchToolResultContent{Results: []WebSearchResult{}}, + want: `[]`, + }, + { + name: "empty content", + w: WebSearchToolResultContent{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.w.MarshalJSON() + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.JSONEq(t, tt.want, string(got)) + }) + } +} + +func TestCacheControl_InTextBlockParam(t *testing.T) { + ttl1h := "1h" + jsonStr := `{"type":"text","text":"Hello","cache_control":{"type":"ephemeral","ttl":"1h"},"citations":[{"type":"char_location","cited_text":"quote","document_index":0,"start_char_index":5,"end_char_index":10}]}` + var param TextBlockParam + err := json.Unmarshal([]byte(jsonStr), ¶m) + require.NoError(t, err) + require.Equal(t, TextBlockParam{ + Type: "text", + Text: "Hello", + CacheControl: &CacheControl{Ephemeral: &CacheControlEphemeral{ + Type: "ephemeral", + TTL: &ttl1h, + }}, + Citations: []TextCitation{ + {CharLocation: &CitationCharLocation{ + Type: "char_location", CitedText: "quote", DocumentIndex: 0, + StartCharIndex: 5, EndCharIndex: 10, + }}, + }, + }, param) + + // Round-trip marshal. + data, err := json.Marshal(param) + require.NoError(t, err) + require.JSONEq(t, jsonStr, string(data)) +} + +func TestCacheControl_InDocumentBlockParam(t *testing.T) { + enabled := true + jsonStr := `{"type":"document","source":{"type":"text","media_type":"text/plain","data":"doc content"},"cache_control":{"type":"ephemeral"},"citations":{"enabled":true},"title":"My Doc","context":"some context"}` + var param DocumentBlockParam + err := json.Unmarshal([]byte(jsonStr), ¶m) + require.NoError(t, err) + require.Equal(t, DocumentBlockParam{ + Type: "document", + Source: DocumentSource{PlainText: &PlainTextSource{Type: "text", MediaType: "text/plain", Data: "doc content"}}, + CacheControl: &CacheControl{Ephemeral: &CacheControlEphemeral{Type: "ephemeral"}}, + Citations: &CitationsConfigParam{Enabled: &enabled}, + Title: "My Doc", + Context: "some context", + }, param) + + // Round-trip marshal. + data, err := json.Marshal(param) + require.NoError(t, err) + require.JSONEq(t, jsonStr, string(data)) +} + +func TestTextBlock_WithCitations(t *testing.T) { + docTitle := "Source Doc" + jsonStr := `{"type":"text","text":"Response with citation","citations":[{"type":"char_location","cited_text":"cited","document_index":0,"document_title":"Source Doc","start_char_index":0,"end_char_index":5},{"type":"web_search_result_location","cited_text":"web cited","encrypted_index":"enc_123","url":"https://example.com"}]}` + var block TextBlock + err := json.Unmarshal([]byte(jsonStr), &block) + require.NoError(t, err) + require.Equal(t, TextBlock{ + Type: "text", + Text: "Response with citation", + Citations: []TextCitation{ + {CharLocation: &CitationCharLocation{ + Type: "char_location", CitedText: "cited", DocumentIndex: 0, + DocumentTitle: &docTitle, StartCharIndex: 0, EndCharIndex: 5, + }}, + {WebSearchResultLocation: &CitationWebSearchResultLocation{ + Type: "web_search_result_location", CitedText: "web cited", + EncryptedIndex: "enc_123", URL: "https://example.com", + }}, + }, + }, block) +} + +func TestImageBlockParam_WithCacheControl(t *testing.T) { + jsonStr := `{"type":"image","source":{"type":"url","url":"https://example.com/img.png"},"cache_control":{"type":"ephemeral"}}` + var param ImageBlockParam + err := json.Unmarshal([]byte(jsonStr), ¶m) + require.NoError(t, err) + require.Equal(t, ImageBlockParam{ + Type: "image", + Source: ImageSource{URL: &URLImageSource{Type: "url", URL: "https://example.com/img.png"}}, + CacheControl: &CacheControl{Ephemeral: &CacheControlEphemeral{Type: "ephemeral"}}, + }, param) +} + +func TestWebSearchToolResultBlockParam_WithError(t *testing.T) { + jsonStr := `{"type":"web_search_tool_result","tool_use_id":"ws_123","content":{"type":"web_search_tool_result_error","error_code":"too_many_requests"}}` + var param WebSearchToolResultBlockParam + err := json.Unmarshal([]byte(jsonStr), ¶m) + require.NoError(t, err) + require.Equal(t, WebSearchToolResultBlockParam{ + Type: "web_search_tool_result", + ToolUseID: "ws_123", + Content: WebSearchToolResultContent{ + Error: &WebSearchToolResultError{ + Type: "web_search_tool_result_error", + ErrorCode: "too_many_requests", + }, + }, + }, param) +} + +func TestDocumentBlockSource_ContentBlockSource(t *testing.T) { + jsonStr := `{"type":"content","content":[{"type":"text","text":"text part"},{"type":"image","source":{"type":"base64","media_type":"image/webp","data":"webp_data"}}]}` + var src ContentBlockSource + err := json.Unmarshal([]byte(jsonStr), &src) + require.NoError(t, err) + require.Equal(t, ContentBlockSource{ + Type: "content", + Content: ContentBlockSourceContent{ + Array: []ContentBlockSourceItem{ + {Text: &TextBlockParam{Type: "text", Text: "text part"}}, + {Image: &ImageBlockParam{ + Type: "image", + Source: ImageSource{Base64: &Base64ImageSource{Type: "base64", MediaType: "image/webp", Data: "webp_data"}}, + }}, + }, + }, + }, src) +} + +func TestToolResultContent_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + jsonStr string + want ToolResultContent + wantErr bool + }{ + { + name: "string content", + jsonStr: `"result text"`, + want: ToolResultContent{Text: "result text"}, + }, + { + name: "array with text block", + jsonStr: `[{"type":"text","text":"result text"}]`, + want: ToolResultContent{Array: []ToolResultContentItem{ + {Text: &TextBlockParam{Type: "text", Text: "result text"}}, + }}, + }, + { + name: "array with image block", + jsonStr: `[{"type":"image","source":{"type":"url","url":"https://example.com/img.png"}}]`, + want: ToolResultContent{Array: []ToolResultContentItem{ + {Image: &ImageBlockParam{ + Type: "image", + Source: ImageSource{URL: &URLImageSource{Type: "url", URL: "https://example.com/img.png"}}, + }}, + }}, + }, + { + name: "array with document block", + jsonStr: `[{"type":"document","source":{"type":"text","media_type":"text/plain","data":"doc content"}}]`, + want: ToolResultContent{Array: []ToolResultContentItem{ + {Document: &DocumentBlockParam{ + Type: "document", + Source: DocumentSource{PlainText: &PlainTextSource{Type: "text", MediaType: "text/plain", Data: "doc content"}}, + }}, + }}, + }, + { + name: "array with search result block", + jsonStr: `[{"type":"search_result","source":"https://example.com","title":"Result","content":[{"type":"text","text":"snippet"}]}]`, + want: ToolResultContent{Array: []ToolResultContentItem{ + {SearchResult: &SearchResultBlockParam{ + Type: "search_result", + Source: "https://example.com", + Title: "Result", + Content: []TextBlockParam{{Type: "text", Text: "snippet"}}, + }}, + }}, + }, + { + name: "invalid content", + jsonStr: `12345`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var c ToolResultContent + err := c.UnmarshalJSON([]byte(tt.jsonStr)) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tt.want, c) + }) + } +} + +func TestToolResultContent_MarshalJSON(t *testing.T) { + tests := []struct { + name string + c ToolResultContent + want string + wantErr bool + }{ + { + name: "string content", + c: ToolResultContent{Text: "result text"}, + want: `"result text"`, + }, + { + name: "array content", + c: ToolResultContent{Array: []ToolResultContentItem{ + {Text: &TextBlockParam{Type: "text", Text: "result text"}}, + }}, + want: `[{"text":"result text","type":"text"}]`, + }, + { + name: "empty content", + c: ToolResultContent{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.c.MarshalJSON() + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.JSONEq(t, tt.want, string(got)) + }) + } +} + +func TestToolResultContentItem_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + jsonStr string + want ToolResultContentItem + wantErr bool + }{ + { + name: "text item", + jsonStr: `{"type":"text","text":"hello"}`, + want: ToolResultContentItem{Text: &TextBlockParam{Type: "text", Text: "hello"}}, + }, + { + name: "image item", + jsonStr: `{"type":"image","source":{"type":"base64","media_type":"image/png","data":"abc"}}`, + want: ToolResultContentItem{Image: &ImageBlockParam{ + Type: "image", + Source: ImageSource{Base64: &Base64ImageSource{Type: "base64", MediaType: "image/png", Data: "abc"}}, + }}, + }, + { + name: "search result item", + jsonStr: `{"type":"search_result","source":"https://example.com","title":"Result","content":[{"type":"text","text":"snippet"}]}`, + want: ToolResultContentItem{SearchResult: &SearchResultBlockParam{ + Type: "search_result", + Source: "https://example.com", + Title: "Result", + Content: []TextBlockParam{{Type: "text", Text: "snippet"}}, + }}, + }, + { + name: "document item", + jsonStr: `{"type":"document","source":{"type":"text","media_type":"text/plain","data":"doc"}}`, + want: ToolResultContentItem{Document: &DocumentBlockParam{ + Type: "document", + Source: DocumentSource{PlainText: &PlainTextSource{Type: "text", MediaType: "text/plain", Data: "doc"}}, + }}, + }, + { + name: "unknown type ignored", + jsonStr: `{"type":"thinking","thinking":"Let me think"}`, + want: ToolResultContentItem{}, + }, + { + name: "missing type", + jsonStr: `{"text":"hello"}`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var item ToolResultContentItem + err := item.UnmarshalJSON([]byte(tt.jsonStr)) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tt.want, item) + }) + } +} + +func TestToolResultContentItem_MarshalJSON(t *testing.T) { + tests := []struct { + name string + item ToolResultContentItem + want string + wantErr bool + }{ + { + name: "text item", + item: ToolResultContentItem{Text: &TextBlockParam{Type: "text", Text: "hello"}}, + want: `{"text":"hello","type":"text"}`, + }, + { + name: "image item", + item: ToolResultContentItem{Image: &ImageBlockParam{ + Type: "image", + Source: ImageSource{URL: &URLImageSource{Type: "url", URL: "https://example.com/img.png"}}, + }}, + want: `{"type":"image","source":{"type":"url","url":"https://example.com/img.png"}}`, + }, + { + name: "search result item", + item: ToolResultContentItem{SearchResult: &SearchResultBlockParam{ + Type: "search_result", + Source: "https://example.com", + Title: "Result", + Content: []TextBlockParam{{Type: "text", Text: "snippet"}}, + }}, + want: `{"type":"search_result","content":[{"text":"snippet","type":"text"}],"source":"https://example.com","title":"Result"}`, + }, + { + name: "document item", + item: ToolResultContentItem{Document: &DocumentBlockParam{ + Type: "document", + Source: DocumentSource{PlainText: &PlainTextSource{Type: "text", MediaType: "text/plain", Data: "doc"}}, + }}, + want: `{"type":"document","source":{"type":"text","data":"doc","media_type":"text/plain"}}`, + }, + { + name: "empty item", + item: ToolResultContentItem{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.item.MarshalJSON() + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.JSONEq(t, tt.want, string(got)) + }) + } +} + +func TestToolResultContentItem_UnmarshalJSON_ErrorPaths(t *testing.T) { + tests := []struct { + name string + jsonStr string + }{ + {name: "text invalid", jsonStr: `{"type":"text","text":123}`}, + {name: "image invalid", jsonStr: `{"type":"image","source":{"type":"url","url":123}}`}, + {name: "search_result invalid", jsonStr: `{"type":"search_result","content":"not_array"}`}, + {name: "document invalid", jsonStr: `{"type":"document","source":null,"context":123}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var item ToolResultContentItem + err := item.UnmarshalJSON([]byte(tt.jsonStr)) + require.Error(t, err) + }) + } +} + +func TestToolResultContent_InToolResultBlockParam(t *testing.T) { + jsonStr := `{"type":"tool_result","tool_use_id":"tu_123","content":[{"type":"text","text":"42"},{"type":"image","source":{"type":"url","url":"https://example.com/chart.png"}}]}` + var param ToolResultBlockParam + err := json.Unmarshal([]byte(jsonStr), ¶m) + require.NoError(t, err) + require.Equal(t, ToolResultBlockParam{ + Type: "tool_result", + ToolUseID: "tu_123", + Content: &ToolResultContent{Array: []ToolResultContentItem{ + {Text: &TextBlockParam{Type: "text", Text: "42"}}, + {Image: &ImageBlockParam{ + Type: "image", + Source: ImageSource{URL: &URLImageSource{Type: "url", URL: "https://example.com/chart.png"}}, + }}, + }}, + }, param) + + // Round-trip marshal. + data, err := json.Marshal(param) + require.NoError(t, err) + require.JSONEq(t, jsonStr, string(data)) +} + +// strPtr is a helper to create a pointer to a string literal. +func strPtr(s string) *string { + return &s +} diff --git a/internal/translator/anthropic_gcpanthropic_test.go b/internal/translator/anthropic_gcpanthropic_test.go index ad6a249af8..542e193bca 100644 --- a/internal/translator/anthropic_gcpanthropic_test.go +++ b/internal/translator/anthropic_gcpanthropic_test.go @@ -122,8 +122,8 @@ func TestAnthropicToGCPAnthropicTranslator_ComprehensiveMarshalling(t *testing.T TopP: func() *float64 { v := 0.95; return &v }(), StopSequences: []string{"Human:", "Assistant:"}, System: &anthropic.SystemPrompt{Text: "You are a helpful weather assistant."}, - Tools: []anthropic.Tool{ - { + Tools: []anthropic.ToolUnion{ + {Tool: &anthropic.Tool{ Name: "get_weather", Description: "Get current weather information", InputSchema: anthropic.ToolInputSchema{ @@ -136,11 +136,9 @@ func TestAnthropicToGCPAnthropicTranslator_ComprehensiveMarshalling(t *testing.T }, Required: []string{"location"}, }, - }, + }}, }, - ToolChoice: ptr.To(anthropic.ToolChoice(map[string]any{ - "type": "auto", - })), + ToolChoice: &anthropic.ToolChoice{Auto: &anthropic.ToolChoiceAuto{Type: "auto"}}, } raw, err := json.Marshal(originalReq) @@ -348,8 +346,8 @@ func TestAnthropicToGCPAnthropicTranslator_RequestBody_FieldPassthrough(t *testi StopSequences: []string{"Human:", "Assistant:"}, Stream: false, System: &anthropic.SystemPrompt{Text: "You are a helpful assistant"}, - Tools: []anthropic.Tool{ - { + Tools: []anthropic.ToolUnion{ + {Tool: &anthropic.Tool{ Name: "get_weather", Description: "Get weather info", InputSchema: anthropic.ToolInputSchema{ @@ -358,12 +356,10 @@ func TestAnthropicToGCPAnthropicTranslator_RequestBody_FieldPassthrough(t *testi "location": map[string]any{"type": "string"}, }, }, - }, + }}, }, - ToolChoice: ptr.To(anthropic.ToolChoice(map[string]any{ - "type": "auto", - })), - Metadata: &anthropic.MessagesMetadata{UserID: ptr.To("test123")}, + ToolChoice: &anthropic.ToolChoice{Auto: &anthropic.ToolChoiceAuto{Type: "auto"}}, + Metadata: &anthropic.MessagesMetadata{UserID: ptr.To("test123")}, } raw, err := json.Marshal(parsedReq)