mirror of
https://gh-proxy.org/https://github.com/klinsmaya/CLIProxyAPI.git
synced 2026-11-11 02:46:47 +08:00
feat(translator): add cache control handling for responses and messages
- Introduced `AttachCacheControl` and `AttachMessageCacheControl` utilities for consistent cache control injection in content parts and messages. - Updated Claude translator modules to preserve and apply cache control metadata for responses, messages, tools, and parts. - Added unit tests to validate proper cache control behavior across multiple scenarios. Closes: #4146
This commit is contained in:
@@ -245,6 +245,9 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
|
||||
// Disable thinking if tool_choice forces tool use (Anthropic API constraint)
|
||||
body = disableThinkingIfToolChoiceForced(body)
|
||||
body = normalizeClaudeSamplingForUpstream(body)
|
||||
// Claude OAuth (and this executor's redact-thinking beta) returns signature-only
|
||||
// thinking blocks unless display is set to "summarized".
|
||||
body = ensureClaudeThinkingDisplay(body)
|
||||
|
||||
// Auto-inject cache_control if missing (optimization for ClawdBot/clients without caching support)
|
||||
if countCacheControls(body) == 0 {
|
||||
@@ -435,6 +438,9 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
|
||||
// Disable thinking if tool_choice forces tool use (Anthropic API constraint)
|
||||
body = disableThinkingIfToolChoiceForced(body)
|
||||
body = normalizeClaudeSamplingForUpstream(body)
|
||||
// Claude OAuth (and this executor's redact-thinking beta) returns signature-only
|
||||
// thinking blocks unless display is set to "summarized".
|
||||
body = ensureClaudeThinkingDisplay(body)
|
||||
|
||||
// Auto-inject cache_control if missing (optimization for ClawdBot/clients without caching support)
|
||||
if countCacheControls(body) == 0 {
|
||||
@@ -891,6 +897,27 @@ func normalizeClaudeSamplingForUpstream(body []byte) []byte {
|
||||
return body
|
||||
}
|
||||
|
||||
// ensureClaudeThinkingDisplay defaults thinking.display to "summarized" when thinking
|
||||
// is active and the client did not set display. Without this, Claude backends that
|
||||
// enable redact-thinking return signature-only thinking blocks (empty thinking text).
|
||||
// Explicit client values such as "omitted" are preserved.
|
||||
func ensureClaudeThinkingDisplay(body []byte) []byte {
|
||||
thinkingType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "thinking.type").String()))
|
||||
switch thinkingType {
|
||||
case "enabled", "adaptive", "auto":
|
||||
default:
|
||||
return body
|
||||
}
|
||||
if display := strings.TrimSpace(gjson.GetBytes(body, "thinking.display").String()); display != "" {
|
||||
return body
|
||||
}
|
||||
out, err := sjson.SetBytes(body, "thinking.display", "summarized")
|
||||
if err != nil {
|
||||
return body
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type compositeReadCloser struct {
|
||||
io.Reader
|
||||
closers []func() error
|
||||
|
||||
@@ -2940,3 +2940,42 @@ func TestRestoreClaudeOAuthToolNamesFromStreamLine_MixedCaseWithPrefix(t *testin
|
||||
t.Fatalf("Glob should be restored to glob, got: %s", string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureClaudeThinkingDisplay_SetsSummarizedWhenMissing(t *testing.T) {
|
||||
payload := []byte(`{"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`)
|
||||
out := ensureClaudeThinkingDisplay(payload)
|
||||
|
||||
if got := gjson.GetBytes(out, "thinking.display").String(); got != "summarized" {
|
||||
t.Fatalf("thinking.display = %q, want summarized", got)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "thinking.type").String(); got != "adaptive" {
|
||||
t.Fatalf("thinking.type = %q, want adaptive", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureClaudeThinkingDisplay_PreservesExplicitValue(t *testing.T) {
|
||||
payload := []byte(`{"thinking":{"type":"enabled","budget_tokens":2048,"display":"omitted"}}`)
|
||||
out := ensureClaudeThinkingDisplay(payload)
|
||||
|
||||
if got := gjson.GetBytes(out, "thinking.display").String(); got != "omitted" {
|
||||
t.Fatalf("thinking.display = %q, want omitted", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureClaudeThinkingDisplay_SkipsWhenThinkingDisabled(t *testing.T) {
|
||||
payload := []byte(`{"thinking":{"type":"disabled"}}`)
|
||||
out := ensureClaudeThinkingDisplay(payload)
|
||||
|
||||
if gjson.GetBytes(out, "thinking.display").Exists() {
|
||||
t.Fatalf("thinking.display should not be set when thinking is disabled: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureClaudeThinkingDisplay_SkipsWhenThinkingMissing(t *testing.T) {
|
||||
payload := []byte(`{"messages":[{"role":"user","content":"hi"}]}`)
|
||||
out := ensureClaudeThinkingDisplay(payload)
|
||||
|
||||
if gjson.GetBytes(out, "thinking").Exists() {
|
||||
t.Fatalf("thinking should remain absent: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
@@ -169,19 +170,35 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream
|
||||
|
||||
switch role {
|
||||
case "system":
|
||||
systemStart := len(gjson.GetBytes(out, "system").Array())
|
||||
if contentResult.Exists() && contentResult.Type == gjson.String && contentResult.String() != "" {
|
||||
textPart := []byte(`{"type":"text","text":""}`)
|
||||
textPart, _ = sjson.SetBytes(textPart, "text", contentResult.String())
|
||||
textPart = common.AttachCacheControl(textPart, message)
|
||||
out, _ = sjson.SetRawBytes(out, "system.-1", textPart)
|
||||
} else if contentResult.Exists() && contentResult.IsArray() {
|
||||
contentResult.ForEach(func(_, part gjson.Result) bool {
|
||||
if part.Get("type").String() == "text" {
|
||||
textPart := []byte(`{"type":"text","text":""}`)
|
||||
textPart, _ = sjson.SetBytes(textPart, "text", part.Get("text").String())
|
||||
textPart = common.AttachCacheControl(textPart, part)
|
||||
out, _ = sjson.SetRawBytes(out, "system.-1", textPart)
|
||||
}
|
||||
return true
|
||||
})
|
||||
// Message-level cache_control applies to the last system block from this message.
|
||||
if message.Get("cache_control").Exists() {
|
||||
systemArr := gjson.GetBytes(out, "system").Array()
|
||||
if len(systemArr) > systemStart {
|
||||
lastIdx := len(systemArr) - 1
|
||||
if !systemArr[lastIdx].Get("cache_control").Exists() {
|
||||
path := fmt.Sprintf("system.%d", lastIdx)
|
||||
block := []byte(systemArr[lastIdx].Raw)
|
||||
block = common.AttachCacheControl(block, message)
|
||||
out, _ = sjson.SetRawBytes(out, path, block)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case "user", "assistant":
|
||||
msg := []byte(`{"role":"","content":[]}`)
|
||||
@@ -240,6 +257,7 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream
|
||||
})
|
||||
}
|
||||
|
||||
msg = common.AttachMessageCacheControl(msg, message)
|
||||
out, _ = sjson.SetRawBytes(out, "messages.-1", msg)
|
||||
messageIndex++
|
||||
|
||||
@@ -257,6 +275,7 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream
|
||||
} else {
|
||||
msg, _ = sjson.SetBytes(msg, "content.0.content", toolResultContent)
|
||||
}
|
||||
msg = common.AttachMessageCacheControl(msg, message)
|
||||
out, _ = sjson.SetRawBytes(out, "messages.-1", msg)
|
||||
messageIndex++
|
||||
}
|
||||
@@ -290,6 +309,10 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream
|
||||
} else if parameters := function.Get("parametersJsonSchema"); parameters.Exists() {
|
||||
anthropicTool, _ = sjson.SetRawBytes(anthropicTool, "input_schema", []byte(parameters.Raw))
|
||||
}
|
||||
anthropicTool = common.AttachCacheControl(anthropicTool, tool)
|
||||
if !gjson.GetBytes(anthropicTool, "cache_control").Exists() {
|
||||
anthropicTool = common.AttachCacheControl(anthropicTool, function)
|
||||
}
|
||||
|
||||
out, _ = sjson.SetRawBytes(out, "tools.-1", anthropicTool)
|
||||
hasAnthropicTools = true
|
||||
@@ -331,14 +354,15 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream
|
||||
}
|
||||
|
||||
func convertOpenAIContentPartToClaudePart(part gjson.Result) string {
|
||||
var claudePart []byte
|
||||
switch part.Get("type").String() {
|
||||
case "text":
|
||||
textPart := []byte(`{"type":"text","text":""}`)
|
||||
textPart, _ = sjson.SetBytes(textPart, "text", part.Get("text").String())
|
||||
return string(textPart)
|
||||
claudePart = textPart
|
||||
|
||||
case "image_url":
|
||||
return convertOpenAIImageURLToClaudePart(part.Get("image_url.url").String())
|
||||
claudePart = []byte(convertOpenAIImageURLToClaudePart(part.Get("image_url.url").String()))
|
||||
|
||||
case "file":
|
||||
fileData := part.Get("file.file_data").String()
|
||||
@@ -351,12 +375,15 @@ func convertOpenAIContentPartToClaudePart(part gjson.Result) string {
|
||||
docPart := []byte(`{"type":"document","source":{"type":"base64","media_type":"","data":""}}`)
|
||||
docPart, _ = sjson.SetBytes(docPart, "source.media_type", mediaType)
|
||||
docPart, _ = sjson.SetBytes(docPart, "source.data", data)
|
||||
return string(docPart)
|
||||
claudePart = docPart
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
if len(claudePart) == 0 {
|
||||
return ""
|
||||
}
|
||||
return string(common.AttachCacheControl(claudePart, part))
|
||||
}
|
||||
|
||||
func convertOpenAIImageURLToClaudePart(imageURL string) string {
|
||||
|
||||
@@ -302,3 +302,107 @@ func TestConvertOpenAIRequestToClaude_SystemOnlyInputKeepsFallbackUserMessage(t
|
||||
t.Fatalf("Expected fallback text %q, got %q", "", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestToClaude_PreservesContentPartCacheControl(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gpt-4.1",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "cached prefix", "cache_control": {"type": "ephemeral"}},
|
||||
{"type": "text", "text": "fresh question"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
|
||||
resultJSON := gjson.ParseBytes(result)
|
||||
|
||||
if got := resultJSON.Get("messages.0.content.0.cache_control.type").String(); got != "ephemeral" {
|
||||
t.Fatalf("content.0.cache_control.type = %q, want ephemeral. Output: %s", got, result)
|
||||
}
|
||||
if resultJSON.Get("messages.0.content.1.cache_control").Exists() {
|
||||
t.Fatalf("content.1 should not have cache_control. Output: %s", result)
|
||||
}
|
||||
if got := resultJSON.Get("messages.0.content.0.text").String(); got != "cached prefix" {
|
||||
t.Fatalf("content.0.text = %q, want %q", got, "cached prefix")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestToClaude_PreservesMessageLevelCacheControl(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gpt-4.1",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "cache me",
|
||||
"cache_control": {"type": "ephemeral", "ttl": "1h"}
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
|
||||
resultJSON := gjson.ParseBytes(result)
|
||||
|
||||
if got := resultJSON.Get("messages.0.content.0.cache_control.type").String(); got != "ephemeral" {
|
||||
t.Fatalf("content.0.cache_control.type = %q, want ephemeral. Output: %s", got, result)
|
||||
}
|
||||
if got := resultJSON.Get("messages.0.content.0.cache_control.ttl").String(); got != "1h" {
|
||||
t.Fatalf("content.0.cache_control.ttl = %q, want 1h. Output: %s", got, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestToClaude_PreservesToolCacheControl(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gpt-4.1",
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "lookup",
|
||||
"description": "Lookup something",
|
||||
"parameters": {"type": "object", "properties": {}}
|
||||
},
|
||||
"cache_control": {"type": "ephemeral"}
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
|
||||
resultJSON := gjson.ParseBytes(result)
|
||||
|
||||
if got := resultJSON.Get("tools.0.cache_control.type").String(); got != "ephemeral" {
|
||||
t.Fatalf("tools.0.cache_control.type = %q, want ephemeral. Output: %s", got, result)
|
||||
}
|
||||
if got := resultJSON.Get("tools.0.name").String(); got != "lookup" {
|
||||
t.Fatalf("tools.0.name = %q, want lookup", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestToClaude_PartCacheControlWinsOverMessageLevel(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gpt-4.1",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"cache_control": {"type": "ephemeral", "ttl": "1h"},
|
||||
"content": [
|
||||
{"type": "text", "text": "part cached", "cache_control": {"type": "ephemeral"}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
|
||||
resultJSON := gjson.ParseBytes(result)
|
||||
|
||||
if got := resultJSON.Get("messages.0.content.0.cache_control.type").String(); got != "ephemeral" {
|
||||
t.Fatalf("content.0.cache_control.type = %q, want ephemeral. Output: %s", got, result)
|
||||
}
|
||||
if resultJSON.Get("messages.0.content.0.cache_control.ttl").Exists() {
|
||||
t.Fatalf("part-level cache_control should win; unexpected ttl: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||
sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
@@ -237,6 +238,7 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
|
||||
textAggregate.WriteString(txt)
|
||||
contentPart := []byte(`{"type":"text","text":""}`)
|
||||
contentPart, _ = sjson.SetBytes(contentPart, "text", txt)
|
||||
contentPart = common.AttachCacheControl(contentPart, part)
|
||||
partsJSON = append(partsJSON, string(contentPart))
|
||||
}
|
||||
if ptype == "input_text" {
|
||||
@@ -272,6 +274,7 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
|
||||
contentPart, _ = sjson.SetBytes(contentPart, "source.url", url)
|
||||
}
|
||||
if len(contentPart) > 0 {
|
||||
contentPart = common.AttachCacheControl(contentPart, part)
|
||||
partsJSON = append(partsJSON, string(contentPart))
|
||||
if role == "" {
|
||||
role = "user"
|
||||
@@ -297,6 +300,7 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
|
||||
contentPart := []byte(`{"type":"document","source":{"type":"base64","media_type":"","data":""}}`)
|
||||
contentPart, _ = sjson.SetBytes(contentPart, "source.media_type", mediaType)
|
||||
contentPart, _ = sjson.SetBytes(contentPart, "source.data", data)
|
||||
contentPart = common.AttachCacheControl(contentPart, part)
|
||||
partsJSON = append(partsJSON, string(contentPart))
|
||||
if role == "" {
|
||||
role = "user"
|
||||
@@ -343,21 +347,24 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
|
||||
if len(partsJSON) > 0 {
|
||||
msg := []byte(`{"role":"","content":[]}`)
|
||||
msg, _ = sjson.SetBytes(msg, "role", role)
|
||||
if len(partsJSON) == 1 && !hasImage && !hasFile && !hasReasoningParts {
|
||||
// Preserve legacy behavior for single text content
|
||||
textPart := gjson.Parse(partsJSON[0])
|
||||
hasPartCacheControl := textPart.Get("cache_control").Exists()
|
||||
if len(partsJSON) == 1 && !hasImage && !hasFile && !hasReasoningParts && !hasPartCacheControl && !item.Get("cache_control").Exists() {
|
||||
// Preserve legacy behavior for single text content without cache markers.
|
||||
msg, _ = sjson.DeleteBytes(msg, "content")
|
||||
textPart := gjson.Parse(partsJSON[0])
|
||||
msg, _ = sjson.SetBytes(msg, "content", textPart.Get("text").String())
|
||||
} else {
|
||||
for _, partJSON := range partsJSON {
|
||||
msg, _ = sjson.SetRawBytes(msg, "content.-1", []byte(partJSON))
|
||||
}
|
||||
}
|
||||
msg = common.AttachMessageCacheControl(msg, item)
|
||||
appendMessage(msg)
|
||||
} else if textAggregate.Len() > 0 || role == "system" {
|
||||
msg := []byte(`{"role":"","content":""}`)
|
||||
msg, _ = sjson.SetBytes(msg, "role", role)
|
||||
msg, _ = sjson.SetBytes(msg, "content", textAggregate.String())
|
||||
msg = common.AttachMessageCacheControl(msg, item)
|
||||
appendMessage(msg)
|
||||
}
|
||||
|
||||
@@ -682,6 +689,10 @@ func convertResponsesFunctionToolToClaude(tool gjson.Result, overrideName string
|
||||
tJSON, _ = sjson.SetBytes(tJSON, "description", d)
|
||||
}
|
||||
tJSON, _ = sjson.SetRawBytes(tJSON, "input_schema", normalizeClaudeToolInputSchema(responsesToolParameters(tool)))
|
||||
tJSON = common.AttachCacheControl(tJSON, tool)
|
||||
if !gjson.GetBytes(tJSON, "cache_control").Exists() {
|
||||
tJSON = common.AttachCacheControl(tJSON, tool.Get("function"))
|
||||
}
|
||||
return tJSON, true
|
||||
}
|
||||
|
||||
|
||||
@@ -321,3 +321,33 @@ func testGPTResponsesReasoningSignature() string {
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(payload)
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToClaude_PreservesContentPartCacheControl(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gpt-4.1",
|
||||
"input": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "input_text", "text": "cached prefix", "cache_control": {"type": "ephemeral"}},
|
||||
{"type": "input_text", "text": "fresh question"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
result := ConvertOpenAIResponsesRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
|
||||
resultJSON := gjson.ParseBytes(result)
|
||||
|
||||
content := resultJSON.Get("messages.0.content")
|
||||
if !content.IsArray() {
|
||||
t.Fatalf("expected content array when cache_control is present, got %s", result)
|
||||
}
|
||||
if got := content.Get("0.cache_control.type").String(); got != "ephemeral" {
|
||||
t.Fatalf("content.0.cache_control.type = %q, want ephemeral. Output: %s", got, result)
|
||||
}
|
||||
if content.Get("1.cache_control").Exists() {
|
||||
t.Fatalf("content.1 should not have cache_control. Output: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// AttachCacheControl copies a Claude-compatible cache_control object from src onto dst.
|
||||
// Returns dst unchanged when cache_control is missing or not an object.
|
||||
func AttachCacheControl(dst []byte, src gjson.Result) []byte {
|
||||
cc := src.Get("cache_control")
|
||||
if !cc.Exists() || cc.Type == gjson.Null || !cc.IsObject() {
|
||||
return dst
|
||||
}
|
||||
out, err := sjson.SetRawBytes(dst, "cache_control", []byte(cc.Raw))
|
||||
if err != nil {
|
||||
return dst
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AttachMessageCacheControl applies message-level cache_control onto the last content block.
|
||||
// Part-level cache_control wins when the last block already has one.
|
||||
// String content is promoted to a content array so Claude can accept cache_control.
|
||||
func AttachMessageCacheControl(msg []byte, src gjson.Result) []byte {
|
||||
cc := src.Get("cache_control")
|
||||
if !cc.Exists() || cc.Type == gjson.Null || !cc.IsObject() {
|
||||
return msg
|
||||
}
|
||||
|
||||
content := gjson.GetBytes(msg, "content")
|
||||
if content.IsArray() {
|
||||
arr := content.Array()
|
||||
if len(arr) == 0 {
|
||||
return msg
|
||||
}
|
||||
lastIdx := len(arr) - 1
|
||||
if arr[lastIdx].Get("cache_control").Exists() {
|
||||
return msg
|
||||
}
|
||||
path := fmt.Sprintf("content.%d.cache_control", lastIdx)
|
||||
out, err := sjson.SetRawBytes(msg, path, []byte(cc.Raw))
|
||||
if err != nil {
|
||||
return msg
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
if content.Type != gjson.String {
|
||||
return msg
|
||||
}
|
||||
|
||||
textPart := []byte(`{"type":"text","text":""}`)
|
||||
textPart, _ = sjson.SetBytes(textPart, "text", content.String())
|
||||
textPart, errSet := sjson.SetRawBytes(textPart, "cache_control", []byte(cc.Raw))
|
||||
if errSet != nil {
|
||||
return msg
|
||||
}
|
||||
out, err := sjson.SetRawBytes(msg, "content", []byte("[]"))
|
||||
if err != nil {
|
||||
return msg
|
||||
}
|
||||
out, _ = sjson.SetRawBytes(out, "content.-1", textPart)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestAttachCacheControl_CopiesObject(t *testing.T) {
|
||||
src := gjson.Parse(`{"text":"hi","cache_control":{"type":"ephemeral","ttl":"5m"}}`)
|
||||
dst := []byte(`{"type":"text","text":"hi"}`)
|
||||
|
||||
out := AttachCacheControl(dst, src)
|
||||
if got := gjson.GetBytes(out, "cache_control.type").String(); got != "ephemeral" {
|
||||
t.Fatalf("cache_control.type = %q, want ephemeral; out=%s", got, out)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "cache_control.ttl").String(); got != "5m" {
|
||||
t.Fatalf("cache_control.ttl = %q, want 5m; out=%s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachCacheControl_IgnoresMissing(t *testing.T) {
|
||||
src := gjson.Parse(`{"text":"hi"}`)
|
||||
dst := []byte(`{"type":"text","text":"hi"}`)
|
||||
|
||||
out := AttachCacheControl(dst, src)
|
||||
if gjson.GetBytes(out, "cache_control").Exists() {
|
||||
t.Fatalf("cache_control should be absent; out=%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachMessageCacheControl_PromotesStringContent(t *testing.T) {
|
||||
src := gjson.Parse(`{"role":"user","content":"hi","cache_control":{"type":"ephemeral"}}`)
|
||||
msg := []byte(`{"role":"user","content":"hi"}`)
|
||||
|
||||
out := AttachMessageCacheControl(msg, src)
|
||||
if got := gjson.GetBytes(out, "content.0.type").String(); got != "text" {
|
||||
t.Fatalf("content.0.type = %q, want text; out=%s", got, out)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "content.0.text").String(); got != "hi" {
|
||||
t.Fatalf("content.0.text = %q, want hi; out=%s", got, out)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "content.0.cache_control.type").String(); got != "ephemeral" {
|
||||
t.Fatalf("content.0.cache_control.type = %q, want ephemeral; out=%s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachMessageCacheControl_SkipsWhenLastPartHasCacheControl(t *testing.T) {
|
||||
src := gjson.Parse(`{"cache_control":{"type":"ephemeral","ttl":"1h"}}`)
|
||||
msg := []byte(`{"role":"user","content":[{"type":"text","text":"hi","cache_control":{"type":"ephemeral"}}]}`)
|
||||
|
||||
out := AttachMessageCacheControl(msg, src)
|
||||
if gjson.GetBytes(out, "content.0.cache_control.ttl").Exists() {
|
||||
t.Fatalf("part-level cache_control should win; out=%s", out)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user