@@ -33,13 +33,34 @@ local tostring = tostring
3333local setmetatable = setmetatable
3434local ngx_re_gsub = ngx .re .gsub
3535local math_max = math.max
36+ local string_sub = string.sub
37+ local string_len = string.len
3638
3739local _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
4465local function make_sse_event (event_type , data )
4566 return { type = event_type , data = core .json .encode (data ) }
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) .
194215local 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
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