Skip to content

Commit f0d6509

Browse files
committed
feat: address review feedback - stream_options, strip cache_control, builtin tools, tool name sanitization, ping event, metadata.user_id, service_tier
- Add stream_options.include_usage=true when stream=true (P0) - Strip cache_control from system, messages, and tools (P0) - Update thinking blocks comment to explain protocol limitation (P0) - Map metadata.user_id to OpenAI user field (P1) - Skip Anthropic built-in tools with debug log (P1) - Handle ping SSE event pass-through (P1) - Tool name truncation/sanitization with mapping restoration (P1) - Passthrough service_tier field (P1)
1 parent d01ce5e commit f0d6509

2 files changed

Lines changed: 395 additions & 63 deletions

File tree

apisix/plugins/ai-protocols/converters/anthropic-messages-to-openai-chat.lua

Lines changed: 113 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,34 @@ local tostring = tostring
3333
local setmetatable = setmetatable
3434
local ngx_re_gsub = ngx.re.gsub
3535
local math_max = math.max
36+
local string_sub = string.sub
37+
local string_len = string.len
3638

3739
local _M = {
3840
from = "anthropic-messages",
3941
to = "openai-chat",
4042
}
4143

4244

45+
-- Anthropic built-in tool type prefixes (no input_schema, OpenAI can't handle them)
46+
local BUILTIN_TOOL_PREFIXES = {
47+
"computer_", "bash_", "text_editor_", "web_search", "code_execution_"
48+
}
49+
50+
-- OpenAI tool name constraints: max 64 chars, only [a-zA-Z0-9_-]
51+
local TOOL_NAME_MAX_LEN = 64
52+
53+
local function sanitize_tool_name(name)
54+
-- Replace invalid characters with underscore
55+
local sanitized = ngx_re_gsub(name, "[^a-zA-Z0-9_-]", "_", "jo")
56+
-- Truncate to max length
57+
if string_len(sanitized) > TOOL_NAME_MAX_LEN then
58+
sanitized = string_sub(sanitized, 1, TOOL_NAME_MAX_LEN)
59+
end
60+
return sanitized
61+
end
62+
63+
4364
-- SSE event helpers
4465
local function make_sse_event(event_type, data)
4566
return { type = event_type, data = core.json.encode(data) }
@@ -190,7 +211,7 @@ end
190211

191212

192213
-- Convert system prompt to OpenAI messages.
193-
-- Preserves array structure with cache_control when present.
214+
-- Always concatenates text blocks into a single string (cache_control is stripped).
194215
local function convert_system(system)
195216
if type(system) == "string" then
196217
if system == "" then
@@ -203,40 +224,7 @@ local function convert_system(system)
203224
return nil
204225
end
205226

206-
-- Check if any block has cache_control
207-
local has_cache_control = false
208-
for _, block in ipairs(system) do
209-
if type(block) == "table" and block.cache_control then
210-
has_cache_control = true
211-
break
212-
end
213-
end
214-
215-
if has_cache_control then
216-
-- Preserve as content array for cache_control transparency
217-
local content = {}
218-
for _, block in ipairs(system) do
219-
if type(block) == "table" and block.type == "text"
220-
and type(block.text) == "string" then
221-
local item = { type = "text", text = block.text }
222-
if block.cache_control then
223-
item.cache_control = block.cache_control
224-
end
225-
-- Strip cch= from billing header blocks
226-
local cleaned = strip_cch_from_billing(block.text)
227-
if cleaned then
228-
item.text = cleaned
229-
table.insert(content, item)
230-
end
231-
end
232-
end
233-
if #content == 0 then
234-
return nil
235-
end
236-
return { role = "system", content = content }
237-
end
238-
239-
-- Simple concatenation when no cache_control
227+
-- Simple concatenation (cache_control stripped: OpenAI doesn't support it)
240228
local parts = {}
241229
for _, block in ipairs(system) do
242230
if type(block) == "table" and block.type == "text"
@@ -277,6 +265,9 @@ function _M.convert_request(request_table, ctx)
277265
-- Stream passthrough
278266
if request_table.stream ~= nil then
279267
openai_body.stream = request_table.stream
268+
if openai_body.stream then
269+
openai_body.stream_options = { include_usage = true }
270+
end
280271
end
281272

282273
-- max_tokens → max_completion_tokens (never forward max_tokens)
@@ -331,6 +322,17 @@ function _M.convert_request(request_table, ctx)
331322
end
332323
end
333324

325+
-- metadata.user_id → user
326+
if type(request_table.metadata) == "table"
327+
and type(request_table.metadata.user_id) == "string" then
328+
openai_body.user = request_table.metadata.user_id
329+
end
330+
331+
-- service_tier passthrough
332+
if type(request_table.service_tier) == "string" then
333+
openai_body.service_tier = request_table.service_tier
334+
end
335+
334336
-- 1. System prompt
335337
local messages = {}
336338
if request_table.system then
@@ -370,9 +372,6 @@ function _M.convert_request(request_table, ctx)
370372

371373
if block.type == "text" and type(block.text) == "string" then
372374
local text_part = { type = "text", text = block.text }
373-
if block.cache_control then
374-
text_part.cache_control = block.cache_control
375-
end
376375
table.insert(content_parts, text_part)
377376

378377
elseif block.type == "image" or block.type == "document" then
@@ -433,8 +432,9 @@ function _M.convert_request(request_table, ctx)
433432
})
434433
end
435434

436-
-- thinking/redacted_thinking blocks are intentionally dropped:
437-
-- OpenAI content parts don't support these Anthropic-specific types.
435+
-- thinking/redacted_thinking blocks are dropped: OpenAI Chat Completions
436+
-- has no equivalent semantics for past reasoning content as input.
437+
-- This is a protocol limitation, not a bug.
438438
end
439439

440440
::CONTINUE_BLOCK::
@@ -479,12 +479,8 @@ function _M.convert_request(request_table, ctx)
479479
-- Multimodal or multi-block: keep as content array
480480
new_msg.content = content_parts
481481
elseif #content_parts == 1 and content_parts[1].type == "text" then
482-
-- Single text block: flatten to string unless it has metadata
483-
if content_parts[1].cache_control then
484-
new_msg.content = content_parts
485-
else
486-
new_msg.content = content_parts[1].text
487-
end
482+
-- Single text block: flatten to string
483+
new_msg.content = content_parts[1].text
488484
else
489485
new_msg.content = ""
490486
end
@@ -497,24 +493,64 @@ function _M.convert_request(request_table, ctx)
497493
-- 3. Convert tools (only when non-empty)
498494
if type(request_table.tools) == "table" and #request_table.tools > 0 then
499495
local openai_tools = {}
500-
for i, tool in ipairs(request_table.tools) do
501-
if type(tool) ~= "table" or type(tool.name) ~= "string" or tool.name == "" then
502-
return nil, "invalid tool definition at index " .. i
496+
local tool_name_map -- lazily created if truncation needed
497+
for _, tool in ipairs(request_table.tools) do
498+
if type(tool) ~= "table" then
499+
goto CONTINUE_TOOL
500+
end
501+
502+
-- Skip Anthropic built-in tools (they have type but no input_schema)
503+
if type(tool.type) == "string" then
504+
local is_builtin = false
505+
for _, prefix in ipairs(BUILTIN_TOOL_PREFIXES) do
506+
if string_sub(tool.type, 1, string_len(prefix)) == prefix then
507+
is_builtin = true
508+
break
509+
end
510+
end
511+
if is_builtin then
512+
core.log.debug("dropping Anthropic built-in tool '", tool.type,
513+
"': not supported by OpenAI upstream")
514+
goto CONTINUE_TOOL
515+
end
516+
end
517+
518+
if type(tool.name) ~= "string" or tool.name == "" then
519+
goto CONTINUE_TOOL
520+
end
521+
522+
-- Sanitize tool name for OpenAI compatibility
523+
local oai_name = tool.name
524+
if string_len(oai_name) > TOOL_NAME_MAX_LEN
525+
or ngx.re.find(oai_name, "[^a-zA-Z0-9_-]", "jo") then
526+
local sanitized = sanitize_tool_name(oai_name)
527+
if sanitized ~= oai_name then
528+
if not tool_name_map then
529+
tool_name_map = {}
530+
end
531+
tool_name_map[sanitized] = oai_name
532+
oai_name = sanitized
533+
end
503534
end
535+
504536
local oai_tool = {
505537
type = "function",
506538
["function"] = {
507-
name = tool.name,
539+
name = oai_name,
508540
description = tool.description,
509541
parameters = tool.input_schema,
510542
},
511543
}
512-
if tool.cache_control then
513-
oai_tool.cache_control = tool.cache_control
514-
end
515544
table.insert(openai_tools, oai_tool)
545+
::CONTINUE_TOOL::
546+
end
547+
if #openai_tools > 0 then
548+
openai_body.tools = openai_tools
549+
end
550+
-- Store tool name mapping in ctx for response restoration
551+
if tool_name_map then
552+
ctx.anthropic_tool_name_map = tool_name_map
516553
end
517-
openai_body.tools = openai_tools
518554
end
519555

520556
return openai_body
@@ -582,6 +618,7 @@ function _M.convert_response(res_body, ctx)
582618
end
583619

584620
-- Tool calls
621+
local tool_name_map = ctx.anthropic_tool_name_map
585622
if msg and type(msg.tool_calls) == "table" then
586623
for _, tc in ipairs(msg.tool_calls) do
587624
local input = {}
@@ -592,10 +629,15 @@ function _M.convert_response(res_body, ctx)
592629
end
593630
input = decoded
594631
end
632+
local tc_name = (tc["function"] and tc["function"].name) or ""
633+
-- Restore original Anthropic tool name if it was sanitized
634+
if tool_name_map and tool_name_map[tc_name] then
635+
tc_name = tool_name_map[tc_name]
636+
end
595637
table.insert(content, {
596638
type = "tool_use",
597639
id = tc.id or "",
598-
name = (tc["function"] and tc["function"].name) or "",
640+
name = tc_name,
599641
input = input,
600642
})
601643
end
@@ -644,7 +686,7 @@ end
644686

645687

646688
--- Convert an OpenAI SSE chunk to Anthropic SSE events.
647-
local function openai_to_anthropic_sse(openai_chunk, state)
689+
local function openai_to_anthropic_sse(openai_chunk, state, tool_name_map)
648690
if type(openai_chunk) ~= "table" then
649691
return {}
650692
end
@@ -789,10 +831,14 @@ local function openai_to_anthropic_sse(openai_chunk, state)
789831
state.current_block_type = "tool_use"
790832

791833
local fn = tc_delta["function"] or {}
834+
local tool_name = fn.name or ""
835+
if tool_name_map and tool_name_map[tool_name] then
836+
tool_name = tool_name_map[tool_name]
837+
end
792838
push_content_block_start(events, idx, {
793839
type = "tool_use",
794840
id = tc_delta.id or "",
795-
name = fn.name or "",
841+
name = tool_name,
796842
input = {},
797843
})
798844
end
@@ -851,15 +897,21 @@ end
851897

852898

853899
--- Convert parsed SSE events (from openai-chat adapter) to Anthropic format.
854-
function _M.convert_sse_events(parsed, _, state)
900+
function _M.convert_sse_events(parsed, ctx, state)
855901
if not parsed or parsed.type == "skip" then
856902
return nil
857903
end
858904

905+
-- Pass-through ping events to keep long-lived connections alive
906+
if parsed.type == "ping" then
907+
return { make_sse_event("ping", { type = "ping" }) }
908+
end
909+
859910
if parsed.type == "done" then
860911
-- Flush any deferred message_stop
861912
if state.pending_stop then
862-
return openai_to_anthropic_sse({ choices = {} }, state)
913+
return openai_to_anthropic_sse({ choices = {} }, state,
914+
ctx and ctx.anthropic_tool_name_map)
863915
end
864916
-- If no pending_stop but stream never finished properly, emit minimal stop
865917
if not state.is_done and state.is_first == false then
@@ -882,7 +934,8 @@ function _M.convert_sse_events(parsed, _, state)
882934
end
883935

884936
if parsed.data then
885-
return openai_to_anthropic_sse(parsed.data, state)
937+
return openai_to_anthropic_sse(parsed.data, state,
938+
ctx and ctx.anthropic_tool_name_map)
886939
end
887940

888941
return nil

0 commit comments

Comments
 (0)