Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions go/plugins/anthropic/cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0

package anthropic

// CacheControlOptions configures an Anthropic ephemeral cache breakpoint.
// Mirrors JS cacheControl() / CacheControlEphemeral.
type CacheControlOptions struct {
// TTL is the cache lifetime: "5m" (default) or "1h".
TTL string
}

// CacheControl returns part metadata that attaches Anthropic prompt caching
// (cache_control) to a content block, matching the JS helper:
//
// metadata: { ...cacheControl({ ttl: '5m' }) }
//
// Merge into an existing map or assign directly to ai.Part.Metadata / system
// text parts:
//
// p := ai.NewTextPart(longSystem)
// p.Metadata = anthropic.CacheControl(&anthropic.CacheControlOptions{TTL: "5m"})
func CacheControl(opts *CacheControlOptions) map[string]any {
cc := map[string]any{"type": "ephemeral"}
if opts != nil && opts.TTL != "" {
cc["ttl"] = opts.TTL
}
return map[string]any{"cache_control": cc}
}
42 changes: 42 additions & 0 deletions go/plugins/anthropic/cache_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0

package anthropic_test

import (
"reflect"
"testing"

"github.com/firebase/genkit/go/plugins/anthropic"
)

func TestCacheControlHelper(t *testing.T) {
got := anthropic.CacheControl(nil)
want := map[string]any{
"cache_control": map[string]any{"type": "ephemeral"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("CacheControl(nil) = %#v, want %#v", got, want)
}

got = anthropic.CacheControl(&anthropic.CacheControlOptions{TTL: "1h"})
want = map[string]any{
"cache_control": map[string]any{"type": "ephemeral", "ttl": "1h"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("CacheControl(1h) = %#v, want %#v", got, want)
}
}
48 changes: 38 additions & 10 deletions go/plugins/internal/anthropic/anthropic.go
Original file line number Diff line number Diff line change
Expand Up @@ -271,12 +271,25 @@ func toAnthropicRequest(provider string, i *ai.ModelRequest) (*anthropic.Message
req.MaxTokens = DefaultMaxOutputTokens
}

// configure system prompt (if given)
// configure system prompt (if given). Preserve per-part cache_control so
// system breakpoints match the JS Anthropic plugin.
sysBlocks := []anthropic.TextBlockParam{}
for _, message := range i.Messages {
if message.Role == ai.RoleSystem {
// only text is supported for system messages
sysBlocks = append(sysBlocks, anthropic.TextBlockParam{Text: message.Text()})
for _, p := range message.Content {
if !p.IsText() {
return nil, fmt.Errorf("system messages can only contain text parts")
}
Comment thread
a2105z marked this conversation as resolved.
tb := anthropic.TextBlockParam{Text: p.Text}
cc, ok, err := cacheControlFromMetadata(p.Metadata)
if err != nil {
return nil, err
}
if ok {
tb.CacheControl = cc
}
sysBlocks = append(sysBlocks, tb)
}
continue
}

Expand Down Expand Up @@ -450,36 +463,43 @@ func toAnthropicParts(parts []*ai.Part) ([]anthropic.ContentBlockParamUnion, err
blocks := []anthropic.ContentBlockParamUnion{}

for _, p := range parts {
var block anthropic.ContentBlockParamUnion
switch {
case p.IsText():
Comment thread
a2105z marked this conversation as resolved.
blocks = append(blocks, anthropic.NewTextBlock(p.Text))
block = anthropic.NewTextBlock(p.Text)
case p.IsMedia():
block, err := toAnthropicMediaBlock(p, "media")
var err error
block, err = toAnthropicMediaBlock(p, "media")
if err != nil {
return nil, err
}
blocks = append(blocks, block)
case p.IsData():
block, err := toAnthropicMediaBlock(p, "data")
var err error
block, err = toAnthropicMediaBlock(p, "data")
if err != nil {
return nil, err
}
blocks = append(blocks, block)
case p.IsToolRequest():
toolReq := p.ToolRequest
blocks = append(blocks, anthropic.NewToolUseBlock(toolReq.Ref, toolReq.Input, toolReq.Name))
block = anthropic.NewToolUseBlock(toolReq.Ref, toolReq.Input, toolReq.Name)
case p.IsToolResponse():
toolResp := p.ToolResponse
output, err := json.Marshal(toolResp.Output)
if err != nil {
return nil, fmt.Errorf("unable to parse tool response, err: %w", err)
}
blocks = append(blocks, anthropic.NewToolResultBlock(toolResp.Ref, string(output), false))
block = anthropic.NewToolResultBlock(toolResp.Ref, string(output), false)
case p.IsReasoning():
// Anthropic rejects cache_control on thinking blocks.
blocks = append(blocks, anthropic.NewThinkingBlock(string(metadataSignature(p.Metadata)), p.Text))
continue
default:
return nil, status.Errorf(ai.ErrInvalidPart, "unknown part type in the request")
}
if err := applyCacheControl(&block, p.Metadata); err != nil {
return nil, err
}
blocks = append(blocks, block)
}

return blocks, nil
Expand Down Expand Up @@ -525,10 +545,18 @@ func toGenkitResponse(m *anthropic.Message) (*ai.ModelResponse, error) {

r.Message = msg
r.Raw = m.JSON
// Mirror JS stable usage.custom cache fields while keeping CachedContentTokens
// as the Go convenience field for cache reads.
r.Usage = &ai.GenerationUsage{
InputTokens: int(m.Usage.InputTokens),
OutputTokens: int(m.Usage.OutputTokens),
CachedContentTokens: int(m.Usage.CacheReadInputTokens),
Custom: map[string]float64{
"cache_creation_input_tokens": float64(m.Usage.CacheCreationInputTokens),
"cache_read_input_tokens": float64(m.Usage.CacheReadInputTokens),
"ephemeral_5m_input_tokens": float64(m.Usage.CacheCreation.Ephemeral5mInputTokens),
"ephemeral_1h_input_tokens": float64(m.Usage.CacheCreation.Ephemeral1hInputTokens),
},
}
return &r, nil
}
86 changes: 86 additions & 0 deletions go/plugins/internal/anthropic/cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0

package anthropic

import (
"fmt"

"github.com/anthropics/anthropic-sdk-go"
)

// cacheControlFromMetadata reads part.metadata.cache_control (JS Anthropic parity).
// Expected shape: {"type":"ephemeral","ttl":"5m"|"1h"}.
func cacheControlFromMetadata(md map[string]any) (anthropic.CacheControlEphemeralParam, bool, error) {
if md == nil {
return anthropic.CacheControlEphemeralParam{}, false, nil
}
raw, ok := md["cache_control"]
if !ok || raw == nil {
return anthropic.CacheControlEphemeralParam{}, false, nil
}
cc, ok := raw.(map[string]any)
if !ok {
return anthropic.CacheControlEphemeralParam{}, false, fmt.Errorf("cache_control must be an object")
}

typ, _ := cc["type"].(string)
if typ == "" {
typ = "ephemeral"
}
if typ != "ephemeral" {
return anthropic.CacheControlEphemeralParam{}, false, fmt.Errorf("unsupported cache_control type %q", typ)
}

param := anthropic.NewCacheControlEphemeralParam()
if ttlRaw, exists := cc["ttl"]; exists && ttlRaw != nil {
ttl, ok := ttlRaw.(string)
if !ok {
return anthropic.CacheControlEphemeralParam{}, false, fmt.Errorf("cache_control.ttl must be a string")
}
switch anthropic.CacheControlEphemeralTTL(ttl) {
case anthropic.CacheControlEphemeralTTLTTL5m, anthropic.CacheControlEphemeralTTLTTL1h:
param.TTL = anthropic.CacheControlEphemeralTTL(ttl)
case "":
// omit → SDK/API default (5m)
default:
return anthropic.CacheControlEphemeralParam{}, false, fmt.Errorf("cache_control.ttl must be %q or %q", anthropic.CacheControlEphemeralTTLTTL5m, anthropic.CacheControlEphemeralTTLTTL1h)
}
}
return param, true, nil
}

// applyCacheControl attaches cache_control to supported content-block variants.
// Thinking / redacted_thinking blocks are left unchanged (API rejects cache on them).
func applyCacheControl(block *anthropic.ContentBlockParamUnion, md map[string]any) error {
cc, ok, err := cacheControlFromMetadata(md)
Comment thread
a2105z marked this conversation as resolved.
if err != nil || !ok {
return err
}
switch {
case block.OfText != nil:
block.OfText.CacheControl = cc
case block.OfImage != nil:
block.OfImage.CacheControl = cc
case block.OfDocument != nil:
block.OfDocument.CacheControl = cc
case block.OfToolUse != nil:
block.OfToolUse.CacheControl = cc
case block.OfToolResult != nil:
block.OfToolResult.CacheControl = cc
}
return nil
}
Loading
Loading