Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
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
18 changes: 18 additions & 0 deletions internal/apischema/awsbedrock/awsbedrock.go
Original file line number Diff line number Diff line change
Expand Up @@ -637,3 +637,21 @@ type TitanEmbeddingResponse struct {
// InputTextTokenCount is the number of tokens in the input text.
InputTextTokenCount int `json:"inputTextTokenCount"`
}

// CountTokensInvokeModelRequest is the request structure for the Bedrock CountTokens API using InvokeModel-style input.
// The body is a base64-encoded model-specific request body (e.g., Anthropic Messages format).
// https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_CountTokens.html
type CountTokensInvokeModelRequest struct {
Input struct {
InvokeModel struct {
Body string `json:"body"`
} `json:"invokeModel"`
} `json:"input"`
}

// CountTokensResponse represents the response structure for the AWS Bedrock CountTokens API
// https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_CountTokens.html
type CountTokensResponse struct {
// The total number of input tokens that were counted
InputTokens int `json:"inputTokens"`
}
4 changes: 4 additions & 0 deletions internal/endpointspec/endpointspec.go
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,10 @@ func (TokenizeEndpointSpec) GetTranslator(schema filterapi.VersionedAPISchema, m
switch schema.Name {
case filterapi.APISchemaOpenAI:
return translator.NewTokenizeTranslator(modelNameOverride), nil
case filterapi.APISchemaGCPAnthropic:
return translator.NewTokenizeToGCPAnthropicTranslator(schema.Version, modelNameOverride), nil
case filterapi.APISchemaAWSAnthropic:
return translator.NewTokenizeToAWSAnthropicTranslator(schema.Version, modelNameOverride), nil
default:
return nil, fmt.Errorf("unsupported API schema for tokenize endpoint: backend=%s", schema.Name)
}
Expand Down
2 changes: 2 additions & 0 deletions internal/endpointspec/endpointspec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,8 @@ func TestTokenizeEndpointSpec_GetTranslator(t *testing.T) {
spec := TokenizeEndpointSpec{}
supported := []filterapi.VersionedAPISchema{
{Name: filterapi.APISchemaOpenAI},
{Name: filterapi.APISchemaGCPAnthropic},
{Name: filterapi.APISchemaAWSAnthropic},
}

for _, schema := range supported {
Expand Down
144 changes: 99 additions & 45 deletions internal/translator/anthropic_helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (

"github.com/envoyproxy/ai-gateway/internal/apischema/awsbedrock"
"github.com/envoyproxy/ai-gateway/internal/apischema/openai"
"github.com/envoyproxy/ai-gateway/internal/apischema/openai/tokenize"
"github.com/envoyproxy/ai-gateway/internal/internalapi"
"github.com/envoyproxy/ai-gateway/internal/json"
"github.com/envoyproxy/ai-gateway/internal/metrics"
Expand All @@ -41,6 +42,42 @@ var anthropicInputSchemaKeysToSkip = map[string]struct{}{
"properties": {},
}

// openAIToolParamsToAnthropicInputSchema converts OpenAI function parameters to an Anthropic ToolInputSchemaParam.
func openAIToolParamsToAnthropicInputSchema(parameters any) (anthropic.ToolInputSchemaParam, error) {
var schema anthropic.ToolInputSchemaParam
if parameters == nil {
return schema, nil
}
paramsMap, ok := parameters.(map[string]any)
if !ok {
return schema, fmt.Errorf("failed to cast tool parameters to map[string]any")
}
if typeVal, ok := paramsMap["type"].(string); ok {
schema.Type = constant.Object(typeVal)
}
if propsVal, ok := paramsMap["properties"].(map[string]any); ok {
schema.Properties = propsVal
}
if requiredVal, ok := paramsMap["required"].([]any); ok {
requiredSlice := make([]string, len(requiredVal))
for i, v := range requiredVal {
if s, ok := v.(string); ok {
requiredSlice[i] = s
}
}
schema.Required = requiredSlice
}
extraFields := make(map[string]any)
for key, value := range paramsMap {
if _, found := anthropicInputSchemaKeysToSkip[key]; found {
continue
}
extraFields[key] = value
}
schema.ExtraFields = extraFields
return schema, nil
}

func anthropicToOpenAIFinishReason(stopReason anthropic.StopReason) (openai.ChatCompletionChoicesFinishReason, error) {
switch stopReason {
// The most common stop reason. Indicates Claude finished its response naturally.
Expand Down Expand Up @@ -148,54 +185,11 @@ func translateOpenAItoAnthropicTools(openAITools []openai.Tool, openAIToolChoice
toolParam.CacheControl = anthropic.NewCacheControlEphemeralParam()
}

// The parameters for the function are expected to be a JSON Schema object.
// We can pass them through as-is.
if openAITool.Function.Parameters != nil {
paramsMap, ok := openAITool.Function.Parameters.(map[string]any)
if !ok {
err = fmt.Errorf("failed to cast tool parameters to map[string]interface{}")
toolParam.InputSchema, err = openAIToolParamsToAnthropicInputSchema(openAITool.Function.Parameters)
if err != nil {
return
}

inputSchema := anthropic.ToolInputSchemaParam{}

var typeVal string
if typeVal, ok = paramsMap["type"].(string); ok {
inputSchema.Type = constant.Object(typeVal)
}

var propsVal map[string]any
if propsVal, ok = paramsMap["properties"].(map[string]any); ok {
inputSchema.Properties = propsVal
}

var requiredVal []any
if requiredVal, ok = paramsMap["required"].([]any); ok {
requiredSlice := make([]string, len(requiredVal))
for i, v := range requiredVal {
if s, ok := v.(string); ok {
requiredSlice[i] = s
}
}
inputSchema.Required = requiredSlice
}

// ExtraFieldsMap to construct
ExtraFieldsMap := make(map[string]any)

// Iterate over the original map from openai
for key, value := range paramsMap {
// Check if the current key should be skipped
if _, found := anthropicInputSchemaKeysToSkip[key]; found {
continue
}

// If not skipped, add the key-value pair to extra field map
ExtraFieldsMap[key] = value
}
inputSchema.ExtraFields = ExtraFieldsMap

toolParam.InputSchema = inputSchema
}

anthropicTools = append(anthropicTools, anthropic.ToolUnionParam{OfTool: &toolParam})
Expand Down Expand Up @@ -1352,3 +1346,63 @@ func messageToChatCompletion(anthropicResp *anthropic.Message, responseModel int
openAIResp.Choices = append(openAIResp.Choices, choice)
return openAIResp, tokenUsage, nil
}

// openAIToAnthropicCountTokensParams builds the Anthropic MessageCountTokensParams
// from an OpenAI-compatible tokenize chat request. Shared by GCP and AWS Anthropic tokenize translators.
//
// Only the fields that affect the counted input tokens are mapped: Messages, Model,
// System, and Tools. Per the Anthropic count_tokens endpoint, the counted input covers
// system prompts, tools, images, PDFs, and current-turn thinking blocks (all of which
// arrive via Messages/System/Tools here). The remaining MessageCountTokensParams fields
// are intentionally omitted because they do not change the returned count:
// - CacheControl: a no-op for token counting; caching only happens during message creation.
// - Thinking: the thinking config adds no input tokens; only thinking blocks already
// present in Messages count.
// - OutputConfig: structured outputs constrain decoding (output), not the input prompt.
// - ToolChoice: a generation-time control, not counted content.
func openAIToAnthropicCountTokensParams(chatReq *tokenize.ChatRequest, model internalapi.RequestModel) (*anthropic.MessageCountTokensParams, error) {
messages, systemBlocks, err := openAIToAnthropicMessages(chatReq.Messages)
Comment thread
hustxiayang marked this conversation as resolved.
if err != nil {
return nil, fmt.Errorf("failed to convert messages: %w", err)
}

params := &anthropic.MessageCountTokensParams{
Messages: messages,
Model: model,
}

if len(systemBlocks) > 0 {
if len(systemBlocks) == 1 {
params.System = anthropic.MessageCountTokensParamsSystemUnion{
OfString: anthropic.String(systemBlocks[0].Text),
}
} else {
textBlocks := make([]anthropic.TextBlockParam, len(systemBlocks))
for i, block := range systemBlocks {
textBlocks[i] = anthropic.TextBlockParam{Text: block.Text}
}
params.System = anthropic.MessageCountTokensParamsSystemUnion{
OfTextBlockArray: textBlocks,
}
}
}

if len(chatReq.Tools) > 0 {
params.Tools = make([]anthropic.MessageCountTokensToolUnionParam, 0, len(chatReq.Tools))
for _, tool := range chatReq.Tools {
if tool.Function == nil {
continue
}
inputSchema, err := openAIToolParamsToAnthropicInputSchema(tool.Function.Parameters)
if err != nil {
return nil, err
}
params.Tools = append(params.Tools, anthropic.MessageCountTokensToolParamOfTool(
inputSchema,
tool.Function.Name,
))
}
}

return params, nil
}
Loading
Loading