Files
kong--kong/spec/03-plugins/38-ai-proxy/09-streaming_integration_spec.lua
2026-07-13 12:32:21 +08:00

1398 lines
54 KiB
Lua

local helpers = require "spec.helpers"
local cjson = require "cjson.safe"
local pl_file = require "pl.file"
local strip = require("kong.tools.string").strip
local http = require("resty.http")
local PLUGIN_NAME = "ai-proxy"
local FILE_LOG_PATH_WITH_PAYLOADS = os.tmpname()
local _EXPECTED_CHAT_STATS = {
meta = {
plugin_id = '6e7c40f6-ce96-48e4-a366-d109c169e444',
provider_name = 'openai',
request_model = 'gpt-3.5-turbo',
response_model = 'gpt-3.5-turbo',
llm_latency = 1
},
usage = {
prompt_tokens = 18,
completion_tokens = 13, -- this was from estimation
total_tokens = 31,
time_per_token = 1,
cost = 0.00031,
},
}
local truncate_file = function(path)
local file = io.open(path, "w")
file:close()
end
local function wait_for_json_log_entry(FILE_LOG_PATH)
local json
assert
.with_timeout(10)
.ignore_exceptions(true)
.eventually(function()
local data = assert(pl_file.read(FILE_LOG_PATH))
data = strip(data)
assert(#data > 0, "log file is empty")
data = data:match("%b{}")
assert(data, "log file does not contain JSON")
json = cjson.decode(data)
end)
.has_no_error("log file contains a valid JSON entry")
return json
end
for _, strategy in helpers.all_strategies() do
describe(PLUGIN_NAME .. ": (access) [#" .. strategy .. "]", function()
local client
local MOCK_PORT
lazy_setup(function()
MOCK_PORT = helpers.get_available_port()
local bp = helpers.get_db_utils(strategy == "off" and "postgres" or strategy, nil, { PLUGIN_NAME })
-- set up openai mock fixtures
local fixtures = {
http_mock = {},
dns_mock = helpers.dns_mock.new({
mocks_only = true, -- don't fallback to "real" DNS
}),
}
fixtures.dns_mock:A {
name = "api.openai.com",
address = "127.0.0.1",
}
fixtures.dns_mock:A {
name = "api.cohere.com",
address = "127.0.0.1",
}
fixtures.http_mock.streams = [[
server {
server_name openai;
listen ]]..MOCK_PORT..[[;
default_type 'application/json';
chunked_transfer_encoding on;
proxy_buffering on;
proxy_buffer_size 600;
proxy_buffers 10 600;
location = "/openai/llm/v1/chat/good" {
content_by_lua_block {
local _EVENT_CHUNKS = {
[1] = 'data: { "choices": [ { "delta": { "content": "", "role": "assistant" }, "finish_reason": null, "index": 0, "logprobs": null } ], "created": 1712538905, "id": "chatcmpl-9BXtBvU8Tsw1U7CarzV71vQEjvYwq", "model": "gpt-4-0613", "object": "chat.completion.chunk", "system_fingerprint": null}',
[2] = 'data: { "choices": [ { "delta": { "content": "The " }, "finish_reason": null, "index": 0, "logprobs": null } ], "created": 1712538905, "id": "chatcmpl-9BXtBvU8Tsw1U7CarzV71vQEjvYwq", "model": "gpt-4-0613", "object": "chat.completion.chunk", "system_fingerprint": null}\n\ndata: { "choices": [ { "delta": { "content": "answer " }, "finish_reason": null, "index": 0, "logprobs": null } ], "created": 1712538905, "id": "chatcmpl-9BXtBvU8Tsw1U7CarzV71vQEjvYwq", "model": "gpt-4-0613", "object": "chat.completion.chunk", "system_fingerprint": null}',
[3] = 'data: { "choices": [ { "delta": { "content": "to 1 + " }, "finish_reason": null, "index": 0, "logprobs": null } ], "created": 1712538905, "id": "chatcmpl-9BXtBvU8Tsw1U7CarzV71vQEjvYwq", "model": "gpt-4-0613", "object": "chat.completion.chunk", "system_fingerprint": null}',
[4] = 'data: { "choices": [ { "delta": { "content": "1 is " }, "finish_reason": null, "index": 0, "logprobs": null } ], "created": 1712538905, "id": "chatcmpl-9BXtBvU8Tsw1U7CarzV71vQEjvYwq", "model": "gpt-4-0613", "object": "chat.completion.chunk", "system_fingerprint": null}\n\ndata: { "choices": [ { "delta": { "content": "2." }, "finish_reason": null, "index": 0, "logprobs": null } ], "created": 1712538905, "id": "chatcmpl-9BXtBvU8Tsw1U7CarzV71vQEjvYwq", "model": "gpt-4-0613", "object": "chat.completion.chunk", "system_fingerprint": null}',
[5] = 'data: { "choices": [ { "delta": {}, "finish_reason": "stop", "index": 0, "logprobs": null } ], "created": 1712538905, "id": "chatcmpl-9BXtBvU8Tsw1U7CarzV71vQEjvYwq", "model": "gpt-4-0613", "object": "chat.completion.chunk", "system_fingerprint": null}',
[6] = 'data: [DONE]',
}
local fmt = string.format
local pl_file = require "pl.file"
local json = require("cjson.safe")
ngx.req.read_body()
local body, err = ngx.req.get_body_data()
body, err = json.decode(body)
local token = ngx.req.get_headers()["authorization"]
local token_query = ngx.req.get_uri_args()["apikey"]
if token == "Bearer openai-key" or token_query == "openai-key" or body.apikey == "openai-key" then
ngx.req.read_body()
local body, err = ngx.req.get_body_data()
body, err = json.decode(body)
if err or (body.messages == ngx.null) then
ngx.status = 400
ngx.print(pl_file.read("spec/fixtures/ai-proxy/openai/llm-v1-chat/responses/bad_request.json"))
else
-- GOOD RESPONSE
ngx.status = 200
ngx.header["Content-Type"] = "text/event-stream"
for i, EVENT in ipairs(_EVENT_CHUNKS) do
ngx.print(fmt("%s\n\n", EVENT))
end
end
else
ngx.status = 401
ngx.print(pl_file.read("spec/fixtures/ai-proxy/openai/llm-v1-chat/responses/unauthorized.json"))
end
}
}
location = "/openai/llm/v1/chat/partial" {
content_by_lua_block {
local _EVENT_CHUNKS = {
[1] = 'data: { "choices": [ { "delta": { "content": "", "role": "assistant" }, "finish_reason": null, "index": 0, "logprobs": null } ], "created": 1712538905, "id": "chatcmpl-9BXtBvU8Tsw1U7CarzV71vQEjvYwq", "model": "gpt-4-0613", "object": "chat.completion.chunk", "system_fingerprint": null}',
[2] = 'data: { "choices": [ { "delta": { "content": "The " }, "finish_reason": null, "index": 0, "logprobs": null } ], "created": 1712538905, "id": "chatcmpl-9BXtBvU8Tsw1U7CarzV71vQEjvYwq", "model": "gpt-4-0613", "object": "chat.completion.chunk", "system_fingerprint": null}\n\ndata: { "choices": [ { "delta": { "content": "answer " }, "finish_reason": null, "index": 0, "logprobs": null } ], "created": 1712538905, "id": "chatcmpl-9BXtBvU8Tsw1U7CarzV71vQEjvYwq", "model": "gpt-4-0613", "object": "chat.completion.chunk", "system_fingerprint": null}',
[3] = 'data: { "choices": [ { "delta": { "content": "to 1 + " }, "finish_reason": null, "index": 0, "logprobs": null } ], "created": 1712538905, "id": "chatcmpl-9BXtBvU8Ts',
[4] = 'w1U7CarzV71vQEjvYwq", "model": "gpt-4-0613", "object": "chat.completion.chunk", "system_fingerprint": null}',
[5] = 'data: { "choices": [ { "delta": { "content": "1 is " }, "finish_reason": null, "index": 0, "logprobs": null } ], "created": 1712538905, "id": "chatcmpl-9BXtBvU8Tsw1U7CarzV71vQEjvYwq", "model": "gpt-4-0613", "object": "chat.completion.chunk", "system_fingerprint": null}\n\ndata: { "choices": [ { "delta": { "content": "2." }, "finish_reason": null, "index": 0, "logprobs": null } ], "created": 1712538905, "id": "chatcmpl-9BXtBvU8Tsw1U7CarzV71vQEjvYwq", "model": "gpt-4-0613", "object": "chat.completion.chunk", "system_fingerprint": null}',
[6] = 'data: { "choices": [ { "delta": {}, "finish_reason": "stop", "index": 0, "logprobs": null } ], "created": 1712538905, "id": "chatcmpl-9BXtBvU8Tsw1U7CarzV71vQEjvYwq", "model": "gpt-4-0613", "object": "chat.completion.chunk", "system_fingerprint": null}',
[7] = 'data: [DONE]',
}
local fmt = string.format
local pl_file = require "pl.file"
local json = require("cjson.safe")
ngx.req.read_body()
local body, err = ngx.req.get_body_data()
body, err = json.decode(body)
local token = ngx.req.get_headers()["authorization"]
local token_query = ngx.req.get_uri_args()["apikey"]
if token == "Bearer openai-key" or token_query == "openai-key" or body.apikey == "openai-key" then
ngx.req.read_body()
local body, err = ngx.req.get_body_data()
body, err = json.decode(body)
if err or (body.messages == ngx.null) then
ngx.status = 400
ngx.print(pl_file.read("spec/fixtures/ai-proxy/openai/llm-v1-chat/responses/bad_request.json"))
else
-- GOOD RESPONSE
ngx.status = 200
ngx.header["Content-Type"] = "text/event-stream"
for i, EVENT in ipairs(_EVENT_CHUNKS) do
-- pretend to truncate chunks
if _EVENT_CHUNKS[i+1] and _EVENT_CHUNKS[i+1]:sub(1, 5) ~= "data:" then
ngx.print(EVENT)
else
ngx.print(fmt("%s\n\n", EVENT))
end
end
end
else
ngx.status = 401
ngx.print(pl_file.read("spec/fixtures/ai-proxy/openai/llm-v1-chat/responses/unauthorized.json"))
end
}
}
location = "/cohere/llm/v1/chat/good" {
content_by_lua_block {
local _EVENT_CHUNKS = {
[1] = '{"is_finished":false,"event_type":"stream-start","generation_id":"3f41d0ea-0d9c-4ecd-990a-88ba46ede663"}',
[2] = '{"is_finished":false,"event_type":"text-generation","text":"1"}',
[3] = '{"is_finished":false,"event_type":"text-generation","text":" +"}',
[4] = '{"is_finished":false,"event_type":"text-generation","text":" 1"}',
[5] = '{"is_finished":false,"event_type":"text-generation","text":" ="}',
[6] = '{"is_finished":false,"event_type":"text-generation","text":" 2"}',
[7] = '{"is_finished":false,"event_type":"text-generation","text":"."}\n\n{"is_finished":false,"event_type":"text-generation","text":" This"}',
[8] = '{"is_finished":false,"event_type":"text-generation","text":" is"}',
[9] = '{"is_finished":false,"event_type":"text-generation","text":" the"}',
[10] = '{"is_finished":false,"event_type":"text-generation","text":" most"}\n\n{"is_finished":false,"event_type":"text-generation","text":" basic"}',
[11] = '{"is_finished":false,"event_type":"text-generation","text":" example"}\n\n{"is_finished":false,"event_type":"text-generation","text":" of"}\n\n{"is_finished":false,"event_type":"text-generation","text":" addition"}',
[12] = '{"is_finished":false,"event_type":"text-generation","text":"."}',
[13] = '{"is_finished":true,"event_type":"stream-end","response":{"response_id":"4658c450-4755-4454-8f9e-a98dd376b9ad","text":"1 + 1 = 2. This is the most basic example of addition.","generation_id":"3f41d0ea-0d9c-4ecd-990a-88ba46ede663","chat_history":[{"role":"USER","message":"What is 1 + 1?"},{"role":"CHATBOT","message":"1 + 1 = 2. This is the most basic example of addition, an arithmetic operation that involves combining two or more numbers together to find their sum. In this case, the numbers being added are both 1, and the answer is 2, meaning 1 + 1 = 2 is an algebraic equation that shows the relationship between these two numbers when added together. This equation is often used as an example of the importance of paying close attention to details when doing math problems, because it is surprising to some people that something so trivial as adding 1 + 1 could ever equal anything other than 2."}],"meta":{"api_version":{"version":"1"},"billed_units":{"input_tokens":57,"output_tokens":123},"tokens":{"input_tokens":68,"output_tokens":123}}},"finish_reason":"COMPLETE"}',
}
local fmt = string.format
local pl_file = require "pl.file"
local json = require("cjson.safe")
ngx.req.read_body()
local body, err = ngx.req.get_body_data()
body, err = json.decode(body)
local token = ngx.req.get_headers()["authorization"]
local token_query = ngx.req.get_uri_args()["apikey"]
if token == "Bearer cohere-key" or token_query == "cohere-key" or body.apikey == "cohere-key" then
ngx.req.read_body()
local body, err = ngx.req.get_body_data()
body, err = json.decode(body)
if err or (body.messages == ngx.null) then
ngx.status = 400
ngx.print(pl_file.read("spec/fixtures/ai-proxy/openai/llm-v1-chat/responses/bad_request.json"))
else
-- GOOD RESPONSE
ngx.status = 200
ngx.header["Content-Type"] = "text/event-stream"
for i, EVENT in ipairs(_EVENT_CHUNKS) do
ngx.print(fmt("%s\n\n", EVENT))
end
end
else
ngx.status = 401
ngx.print(pl_file.read("spec/fixtures/ai-proxy/openai/llm-v1-chat/responses/unauthorized.json"))
end
}
}
location = "/anthropic/llm/v1/chat/good" {
content_by_lua_block {
local _EVENT_CHUNKS = {
[1] = 'event: message_start',
[2] = 'event: content_block_start',
[3] = 'event: ping',
[4] = 'event: content_block_delta',
[5] = 'event: content_block_delta',
[6] = 'event: content_block_delta',
[7] = 'event: content_block_delta',
[8] = 'event: content_block_delta',
[9] = 'event: content_block_stop',
[10] = 'event: message_delta',
[11] = 'event: message_stop',
}
local _DATA_CHUNKS = {
[1] = 'data: {"type":"message_start","message":{"id":"msg_013NVLwA2ypoPDJAxqC3G7wg","type":"message","role":"assistant","model":"claude-2.1","stop_sequence":null,"usage":{"input_tokens":15,"output_tokens":1},"content":[],"stop_reason":null} }',
[2] = 'data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""} }',
[3] = 'data: {"type": "ping"}',
[4] = 'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"1"} }',
[5] = 'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" +"} }',
[6] = 'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" 1"} }',
[7] = 'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" ="} }',
[8] = 'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" 2"} }',
[9] = 'data: {"type":"content_block_stop","index":0 }',
[10] = 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":9}}',
[11] = 'data: {"type":"message_stop"}',
}
local fmt = string.format
local pl_file = require "pl.file"
local json = require("cjson.safe")
ngx.req.read_body()
local body, err = ngx.req.get_body_data()
body, err = json.decode(body)
local token = ngx.req.get_headers()["api-key"]
local token_query = ngx.req.get_uri_args()["apikey"]
if token == "anthropic-key" or token_query == "anthropic-key" or body.apikey == "anthropic-key" then
ngx.req.read_body()
local body, err = ngx.req.get_body_data()
body, err = json.decode(body)
if err or (body.messages == ngx.null) then
ngx.status = 400
ngx.print(pl_file.read("spec/fixtures/ai-proxy/openai/llm-v1-chat/responses/bad_request.json"))
else
-- GOOD RESPONSE
ngx.status = 200
ngx.header["Content-Type"] = "text/event-stream"
for i, EVENT in ipairs(_EVENT_CHUNKS) do
ngx.print(fmt("%s\n", EVENT))
ngx.print(fmt("%s\n\n", _DATA_CHUNKS[i]))
end
end
else
ngx.status = 401
ngx.print(pl_file.read("spec/fixtures/ai-proxy/openai/llm-v1-chat/responses/unauthorized.json"))
end
}
}
location = "/openai/llm/v1/chat/bad" {
content_by_lua_block {
local fmt = string.format
local pl_file = require "pl.file"
local json = require("cjson.safe")
ngx.req.read_body()
local body, err = ngx.req.get_body_data()
body, err = json.decode(body)
local token = ngx.req.get_headers()["authorization"]
local token_query = ngx.req.get_uri_args()["apikey"]
if token == "Bearer openai-key" or token_query == "openai-key" or body.apikey == "openai-key" then
ngx.req.read_body()
local body, err = ngx.req.get_body_data()
body, err = json.decode(body)
if err or (body.messages == ngx.null) then
ngx.status = 400
ngx.print(pl_file.read("spec/fixtures/ai-proxy/openai/llm-v1-chat/responses/bad_request.json"))
else
-- BAD RESPONSE
ngx.status = 400
ngx.say('{"error": { "message": "failure of some kind" }}')
end
else
ngx.status = 401
ngx.print(pl_file.read("spec/fixtures/ai-proxy/openai/llm-v1-chat/responses/unauthorized.json"))
end
}
}
location = "/gemini/llm/v1/chat/functions/good" {
content_by_lua_block {
local _EVENT_CHUNKS = {
[1] = '[{\r\n "candidates": [\r\n {\r\n "content": {\r\n "role": "model",\r\n "parts": [\r\n {\r\n "functionCall": {\r\n "name": "add",\r\n "args": {\r\n "a": 2,\r\n "b": 12\r\n }\r\n }\r\n }\r\n ]\r\n },\r\n',
[2] = ' "finishReason": "STOP",\r\n "safetyRatings": [\r\n {\r\n "category": "HARM_CATEGORY_HATE_SPEECH",\r\n "probability": "NEGLIGIBLE",\r\n "probabilityScore": 0.10498047,\r\n "severity": "HARM_SEVERITY_NEGLIGIBLE",\r\n "severityScore": 0.09423828\r\n },\r\n {\r\n "category": "HARM_CATEGORY_DANGEROUS_CONTENT",\r\n "probability": "NEGLIGIBLE",\r\n "probabilityScore": 0.23535156,\r\n "severity": "HARM_SEVERITY_NEGLIGIBLE",\r\n "severityScore": 0.13378906\r\n },\r\n {\r\n "category": "HARM_CATEGORY_HARASSMENT",\r\n "probability": "NEGLIGIBLE",\r\n "probabilityScore": 0.15917969,\r\n "severity": "HARM_SEVERITY_NEGLIGIBLE",\r\n "severityScore": 0.09814453\r\n },\r\n {\r\n "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",\r\n "probability": "NEGLIGIBLE",\r\n "probabilityScore": 0.09033203,\r\n "severity": "HARM_SEVERITY_NEGLIGIBLE",\r\n "severityScore": 0.087402344\r\n }\r\n ]\r\n }\r\n ],\r\n "usageMetadata": {\r\n "promptTokenCount": 47,\r\n "candidatesTokenCount": 3,\r\n "totalTokenCount": 50,\r\n "promptTokensDetails": [\r\n {\r\n "modality": "TEXT",\r\n "tokenCount": 47\r\n }\r\n ],\r\n "candidatesTokensDetails": [\r\n {\r\n "modality": "TEXT",\r\n "tokenCount": 3\r\n }\r\n ]\r\n },\r\n "modelVersion": "gemini-1.5-pro-001",\r\n "createTime": "2025-02-20T21:58:56.381597Z",\r\n "responseId": "oKW3Z52lF4762PgP4P6i4Ak"\r\n}',
[3] = ']',
}
local fmt = string.format
local pl_file = require "pl.file"
local json = require("cjson.safe")
ngx.req.read_body()
-- GOOD RESPONSE
ngx.status = 200
ngx.header["Content-Type"] = "application/json"
for i, EVENT in ipairs(_EVENT_CHUNKS) do
ngx.print(fmt("%s\n\n", EVENT))
end
}
}
location = "/bedrock/llm/v1/chat/functions/good" {
content_by_lua_block {
local pl_file = require "pl.file"
local _EVENT_CHUNKS = {}
for i=1,3 do
local encoded = pl_file.read("spec/fixtures/ai-proxy/bedrock/chunks/chunk-" .. i .. ".txt")
local decoded = ngx.decode_base64(encoded)
_EVENT_CHUNKS[i] = decoded
end
local fmt = string.format
local json = require("cjson.safe")
ngx.req.read_body()
-- GOOD RESPONSE
ngx.status = 200
ngx.header["Content-Type"] = "application/vnd.amazon.eventstream"
for i, EVENT in ipairs(_EVENT_CHUNKS) do
ngx.print(fmt("%s", EVENT))
end
}
}
}
]]
local empty_service = assert(bp.services:insert {
name = "empty_service",
host = "localhost",
port = 8080,
path = "/",
})
-- 200 chat openai
local openai_chat_good = assert(bp.routes:insert {
service = empty_service,
protocols = { "http" },
strip_path = true,
paths = { "/openai/llm/v1/chat/good" }
})
bp.plugins:insert {
name = PLUGIN_NAME,
route = { id = openai_chat_good.id },
config = {
route_type = "llm/v1/chat",
auth = {
header_name = "Authorization",
header_value = "Bearer openai-key",
},
model = {
name = "gpt-3.5-turbo",
provider = "openai",
options = {
max_tokens = 256,
temperature = 1.0,
upstream_url = "http://"..helpers.mock_upstream_host..":"..MOCK_PORT.."/openai/llm/v1/chat/good",
input_cost = 10.0,
output_cost = 10.0,
},
},
},
}
bp.plugins:insert {
name = "file-log",
route = { id = openai_chat_good.id },
config = {
path = "/dev/stdout",
},
}
--
-- 200 chat openai - PARTIAL SPLIT CHUNKS
local openai_chat_partial = assert(bp.routes:insert {
service = empty_service,
protocols = { "http" },
strip_path = true,
paths = { "/openai/llm/v1/chat/partial" }
})
bp.plugins:insert {
name = PLUGIN_NAME,
id = "6e7c40f6-ce96-48e4-a366-d109c169e444",
route = { id = openai_chat_partial.id },
config = {
route_type = "llm/v1/chat",
auth = {
header_name = "Authorization",
header_value = "Bearer openai-key",
},
logging = {
log_payloads = true,
log_statistics = true,
},
model = {
name = "gpt-3.5-turbo",
provider = "openai",
options = {
max_tokens = 256,
temperature = 1.0,
upstream_url = "http://"..helpers.mock_upstream_host..":"..MOCK_PORT.."/openai/llm/v1/chat/partial",
input_cost = 10.0,
output_cost = 10.0,
},
},
},
}
bp.plugins:insert {
name = "file-log",
route = { id = openai_chat_partial.id },
config = {
path = FILE_LOG_PATH_WITH_PAYLOADS,
},
}
--
-- 200 chat cohere
local cohere_chat_good = assert(bp.routes:insert {
service = empty_service,
protocols = { "http" },
strip_path = true,
paths = { "/cohere/llm/v1/chat/good" }
})
bp.plugins:insert {
name = PLUGIN_NAME,
route = { id = cohere_chat_good.id },
config = {
route_type = "llm/v1/chat",
auth = {
header_name = "Authorization",
header_value = "Bearer cohere-key",
},
model = {
name = "command",
provider = "cohere",
options = {
max_tokens = 256,
temperature = 1.0,
upstream_url = "http://"..helpers.mock_upstream_host..":"..MOCK_PORT.."/cohere/llm/v1/chat/good",
input_cost = 10.0,
output_cost = 10.0,
},
},
},
}
bp.plugins:insert {
name = "file-log",
route = { id = cohere_chat_good.id },
config = {
path = "/dev/stdout",
},
}
--
-- 200 chat anthropic
local anthropic_chat_good = assert(bp.routes:insert {
service = empty_service,
protocols = { "http" },
strip_path = true,
paths = { "/anthropic/llm/v1/chat/good" }
})
bp.plugins:insert {
name = PLUGIN_NAME,
route = { id = anthropic_chat_good.id },
config = {
route_type = "llm/v1/chat",
auth = {
header_name = "api-key",
header_value = "anthropic-key",
},
model = {
name = "claude-2.1",
provider = "anthropic",
options = {
max_tokens = 256,
temperature = 1.0,
upstream_url = "http://"..helpers.mock_upstream_host..":"..MOCK_PORT.."/anthropic/llm/v1/chat/good",
anthropic_version = "2023-06-01",
input_cost = 10.0,
output_cost = 10.0,
},
},
},
}
bp.plugins:insert {
name = "file-log",
route = { id = anthropic_chat_good.id },
config = {
path = "/dev/stdout",
},
}
--
-- 400 chat openai
local openai_chat_bad = assert(bp.routes:insert {
service = empty_service,
protocols = { "http" },
strip_path = true,
paths = { "/openai/llm/v1/chat/bad" }
})
bp.plugins:insert {
name = PLUGIN_NAME,
route = { id = openai_chat_bad.id },
config = {
route_type = "llm/v1/chat",
auth = {
header_name = "Authorization",
header_value = "Bearer openai-key",
},
model = {
name = "gpt-3.5-turbo",
provider = "openai",
options = {
max_tokens = 256,
temperature = 1.0,
upstream_url = "http://"..helpers.mock_upstream_host..":"..MOCK_PORT.."/openai/llm/v1/chat/bad",
input_cost = 10.0,
output_cost = 10.0,
},
},
},
}
bp.plugins:insert {
name = "file-log",
route = { id = openai_chat_bad.id },
config = {
path = "/dev/stdout",
},
}
--
-- 200 chat gemini with functions
local gemini_chat_functions_good = assert(bp.routes:insert {
service = empty_service,
protocols = { "http" },
strip_path = true,
paths = { "/gemini/llm/v1/chat/functions/good" }
})
bp.plugins:insert {
name = PLUGIN_NAME,
route = { id = gemini_chat_functions_good.id },
config = {
route_type = "llm/v1/chat",
logging = {
log_payloads = false,
log_statistics = true,
},
model = {
name = "gemini-1.5-flash",
provider = "gemini",
options = {
max_tokens = 512,
temperature = 0.6,
upstream_url = "http://"..helpers.mock_upstream_host..":"..MOCK_PORT.."/gemini/llm/v1/chat/functions/good",
input_cost = 20.0,
output_cost = 20.0,
},
},
auth = {
header_name = "x-goog-api-key",
header_value = "123",
allow_override = false,
},
},
}
bp.plugins:insert {
name = "file-log",
route = { id = gemini_chat_functions_good.id },
config = {
path = "/dev/stdout",
},
}
--
-- 200 chat bedrock with functions
local bedrock_chat_functions_good = assert(bp.routes:insert {
service = empty_service,
protocols = { "http" },
strip_path = true,
paths = { "/bedrock/llm/v1/chat/functions/good" }
})
bp.plugins:insert {
name = PLUGIN_NAME,
route = { id = bedrock_chat_functions_good.id },
config = {
route_type = "llm/v1/chat",
logging = {
log_payloads = false,
log_statistics = true,
},
model = {
name = "aws-titan-v1:0",
provider = "bedrock",
options = {
max_tokens = 512,
temperature = 0.6,
upstream_url = "http://"..helpers.mock_upstream_host..":"..MOCK_PORT.."/bedrock/llm/v1/chat/functions/good",
input_cost = 20.0,
output_cost = 20.0,
},
},
auth = {
allow_override = false,
aws_access_key_id = "mock-key",
aws_secret_access_key = "mock-secret",
},
},
}
bp.plugins:insert {
name = "file-log",
route = { id = bedrock_chat_functions_good.id },
config = {
path = "/dev/stdout",
},
}
--
-- 200 chat gemini with functions and native format
local gemini_chat_functions_good_native = assert(bp.routes:insert {
service = empty_service,
protocols = { "http" },
strip_path = true,
paths = { "/gemini-native/llm/v1/chat/functions/good" }
})
bp.plugins:insert {
name = PLUGIN_NAME,
route = { id = gemini_chat_functions_good_native.id },
config = {
route_type = "llm/v1/chat",
llm_format = "gemini",
logging = {
log_payloads = false,
log_statistics = true,
},
model = {
name = "gemini-1.5-flash",
provider = "gemini",
options = {
max_tokens = 512,
temperature = 0.6,
upstream_url = "http://"..helpers.mock_upstream_host..":"..MOCK_PORT.."/gemini/llm/v1/chat/functions/good",
input_cost = 20.0,
output_cost = 20.0,
},
},
auth = {
header_name = "x-goog-api-key",
header_value = "123",
allow_override = false,
},
},
}
bp.plugins:insert {
name = "file-log",
route = { id = gemini_chat_functions_good_native.id },
config = {
path = "/dev/stdout",
},
}
--
-- 200 chat bedrock with functions and native format
local bedrock_chat_functions_good_native = assert(bp.routes:insert {
service = empty_service,
protocols = { "http" },
strip_path = true,
paths = { "/bedrock-native/llm/v1/chat/functions/good" }
})
bp.plugins:insert {
name = PLUGIN_NAME,
route = { id = bedrock_chat_functions_good_native.id },
config = {
route_type = "llm/v1/chat",
llm_format = "bedrock",
logging = {
log_payloads = false,
log_statistics = true,
},
model = {
name = "aws-titan-v1:0",
provider = "bedrock",
options = {
max_tokens = 512,
temperature = 0.6,
upstream_url = "http://"..helpers.mock_upstream_host..":"..MOCK_PORT.."/bedrock/llm/v1/chat/functions/good",
input_cost = 20.0,
output_cost = 20.0,
},
},
auth = {
allow_override = false,
aws_access_key_id = "mock-key",
aws_secret_access_key = "mock-secret",
},
},
}
bp.plugins:insert {
name = "file-log",
route = { id = bedrock_chat_functions_good_native.id },
config = {
path = "/dev/stdout",
},
}
--
helpers.setenv("AWS_REGION", "us-east-1")
-- start kong
assert(helpers.start_kong({
-- set the strategy
database = strategy,
-- use the custom test template to create a local mock server
nginx_conf = "spec/fixtures/custom_nginx.template",
-- make sure our plugin gets loaded
plugins = "bundled," .. PLUGIN_NAME,
-- write & load declarative config, only if 'strategy=off'
declarative_config = strategy == "off" and helpers.make_yaml_file() or nil,
}, nil, nil, fixtures))
end)
lazy_teardown(function()
helpers.unsetenv("AWS_REGION")
helpers.stop_kong()
os.remove(FILE_LOG_PATH_WITH_PAYLOADS)
end)
before_each(function()
client = helpers.proxy_client()
truncate_file(FILE_LOG_PATH_WITH_PAYLOADS)
end)
after_each(function()
if client then client:close() end
end)
describe("stream llm/v1/chat", function()
it("good stream request openai", function()
local httpc = http.new()
local ok, err, _ = httpc:connect({
scheme = "http",
host = helpers.mock_upstream_host,
port = helpers.get_proxy_port(),
})
if not ok then
assert.is_nil(err)
end
-- Then send using `request`, supplying a path and `Host` header instead of a
-- full URI.
local res, err = httpc:request({
path = "/openai/llm/v1/chat/good",
body = pl_file.read("spec/fixtures/ai-proxy/openai/llm-v1-chat/requests/good-stream.json"),
headers = {
["content-type"] = "application/json",
["accept"] = "application/json",
},
})
if not res then
assert.is_nil(err)
end
local reader = res.body_reader
local buffer_size = 35536
local events = {}
local buf = require("string.buffer").new()
-- extract event
repeat
-- receive next chunk
local buffer, err = reader(buffer_size)
if err then
assert.is_falsy(err and err ~= "closed")
end
if buffer then
-- we need to rip each message from this chunk
for s in buffer:gmatch("[^\r\n]+") do
local s_copy = s
s_copy = string.sub(s_copy,7)
s_copy = cjson.decode(s_copy)
buf:put(s_copy
and s_copy.choices
and s_copy.choices
and s_copy.choices[1]
and s_copy.choices[1].delta
and s_copy.choices[1].delta.content
or "")
table.insert(events, s)
end
end
until not buffer
assert.equal(#events, 8)
assert.equal(buf:tostring(), "The answer to 1 + 1 is 2.")
-- to verifiy not enable `kong.service.request.enable_buffering()`
assert.logfile().has.no.line("/kong_buffered_http", true, 10)
end)
it("good stream request openai with partial split chunks", function()
local httpc = http.new()
local ok, err, _ = httpc:connect({
scheme = "http",
host = helpers.mock_upstream_host,
port = helpers.get_proxy_port(),
})
if not ok then
assert.is_nil(err)
end
-- Then send using `request`, supplying a path and `Host` header instead of a
-- full URI.
local res, err = httpc:request({
path = "/openai/llm/v1/chat/partial",
body = pl_file.read("spec/fixtures/ai-proxy/openai/llm-v1-chat/requests/good-stream.json"),
headers = {
["content-type"] = "application/json",
["accept"] = "application/json",
},
})
if not res then
assert.is_nil(err)
end
local reader = res.body_reader
local buffer_size = 35536
local events = {}
local buf = require("string.buffer").new()
-- extract event
repeat
-- receive next chunk
local buffer, err = reader(buffer_size)
if err then
assert.is_falsy(err and err ~= "closed")
end
if buffer then
-- we need to rip each message from this chunk
for s in buffer:gmatch("[^\r\n]+") do
local s_copy = s
s_copy = string.sub(s_copy,7)
s_copy = cjson.decode(s_copy)
buf:put(s_copy
and s_copy.choices
and s_copy.choices
and s_copy.choices[1]
and s_copy.choices[1].delta
and s_copy.choices[1].delta.content
or "")
table.insert(events, s)
end
end
until not buffer
assert.equal(#events, 8)
assert.equal(buf:tostring(), "The answer to 1 + 1 is 2.")
-- test analytics on this item
local log_message = wait_for_json_log_entry(FILE_LOG_PATH_WITH_PAYLOADS)
assert.same("127.0.0.1", log_message.client_ip)
assert.is_number(log_message.request.size)
assert.is_number(log_message.response.size)
local actual_stats = log_message.ai.proxy
local actual_llm_latency = actual_stats.meta.llm_latency
local actual_time_per_token = actual_stats.usage.time_per_token
local time_per_token = actual_llm_latency / actual_stats.usage.completion_tokens
local actual_request_log = actual_stats.payload.request or "ERROR: NONE_RETURNED"
local actual_response_log = actual_stats.payload.response or "ERROR: NONE_RETURNED"
actual_stats.payload = nil
actual_stats.meta.llm_latency = 1
actual_stats.usage.time_per_token = 1
assert.same(_EXPECTED_CHAT_STATS, actual_stats)
assert.is_true(actual_llm_latency >= 0)
assert.same(tonumber(string.format("%.3f", actual_time_per_token)), tonumber(string.format("%.3f", time_per_token)))
assert.match_re(actual_request_log, [[.*content.*What is 1 \+ 1.*]])
assert.match_re(actual_response_log, [[.*content.*The answer.*]])
-- to verifiy not enable `kong.service.request.enable_buffering()`
assert.logfile().has.no.line("/kong_buffered_http", true, 10)
end)
it("good stream request cohere", function()
local httpc = http.new()
local ok, err, _ = httpc:connect({
scheme = "http",
host = helpers.mock_upstream_host,
port = helpers.get_proxy_port(),
})
if not ok then
assert.is_nil(err)
end
-- Then send using `request`, supplying a path and `Host` header instead of a
-- full URI.
local res, err = httpc:request({
path = "/cohere/llm/v1/chat/good",
body = pl_file.read("spec/fixtures/ai-proxy/cohere/llm-v1-chat/requests/good-stream.json"),
headers = {
["content-type"] = "application/json",
["accept"] = "application/json",
},
})
if not res then
assert.is_nil(err)
end
local reader = res.body_reader
local buffer_size = 35536
local events = {}
local buf = require("string.buffer").new()
-- extract event
repeat
-- receive next chunk
local buffer, err = reader(buffer_size)
if err then
assert.is_falsy(err and err ~= "closed")
end
if buffer then
-- we need to rip each message from this chunk
for s in buffer:gmatch("[^\r\n]+") do
local s_copy = s
s_copy = string.sub(s_copy,7)
s_copy = cjson.decode(s_copy)
buf:put(s_copy
and s_copy.choices
and s_copy.choices
and s_copy.choices[1]
and s_copy.choices[1].delta
and s_copy.choices[1].delta.content
or "")
table.insert(events, s)
end
end
until not buffer
assert.equal(#events, 17)
assert.equal(buf:tostring(), "1 + 1 = 2. This is the most basic example of addition.")
-- to verifiy not enable `kong.service.request.enable_buffering()`
assert.logfile().has.no.line("/kong_buffered_http", true, 10)
end)
it("good stream request anthropic", function()
local httpc = http.new()
local ok, err, _ = httpc:connect({
scheme = "http",
host = helpers.mock_upstream_host,
port = helpers.get_proxy_port(),
})
if not ok then
assert.is_nil(err)
end
-- Then send using `request`, supplying a path and `Host` header instead of a
-- full URI.
local res, err = httpc:request({
path = "/anthropic/llm/v1/chat/good",
body = pl_file.read("spec/fixtures/ai-proxy/anthropic/llm-v1-chat/requests/good-stream.json"),
headers = {
["content-type"] = "application/json",
["accept"] = "application/json",
},
})
if not res then
assert.is_nil(err)
end
local reader = res.body_reader
local buffer_size = 35536
local events = {}
local buf = require("string.buffer").new()
-- extract event
repeat
-- receive next chunk
local buffer, err = reader(buffer_size)
if err then
assert.is_falsy(err and err ~= "closed")
end
if buffer then
-- we need to rip each message from this chunk
for s in buffer:gmatch("[^\r\n]+") do
local s_copy = s
s_copy = string.sub(s_copy,7)
s_copy = cjson.decode(s_copy)
buf:put(s_copy
and s_copy.choices
and s_copy.choices
and s_copy.choices[1]
and s_copy.choices[1].delta
and s_copy.choices[1].delta.content
or "")
table.insert(events, s)
end
end
until not buffer
assert.equal(#events, 8)
assert.equal(buf:tostring(), "1 + 1 = 2")
-- to verifiy not enable `kong.service.request.enable_buffering()`
assert.logfile().has.no.line("/kong_buffered_http", true, 10)
end)
it("bad request is returned to the client not-streamed", function()
local httpc = http.new()
local ok, err, _ = httpc:connect({
scheme = "http",
host = helpers.mock_upstream_host,
port = helpers.get_proxy_port(),
})
if not ok then
assert.is_nil(err)
end
-- Then send using `request`, supplying a path and `Host` header instead of a
-- full URI.
local res, err = httpc:request({
path = "/openai/llm/v1/chat/bad",
body = pl_file.read("spec/fixtures/ai-proxy/openai/llm-v1-chat/requests/good-stream.json"),
headers = {
["content-type"] = "application/json",
["accept"] = "application/json",
},
})
if not res then
assert.is_nil(err)
end
local reader = res.body_reader
local buffer_size = 35536
local events = {}
-- extract event
repeat
-- receive next chunk
local buffer, err = reader(buffer_size)
if err then
assert.is_nil(err)
end
if buffer then
-- we need to rip each message from this chunk
for s in buffer:gmatch("[^\r\n]+") do
table.insert(events, s)
end
end
until not buffer
assert.equal(#events, 1)
assert.equal(res.status, 400)
-- to verifiy not enable `kong.service.request.enable_buffering()`
assert.logfile().has.no.line("/kong_buffered_http", true, 10)
end)
it("good stream request gemini with function calls", function()
local httpc = http.new()
local ok, err, _ = httpc:connect({
scheme = "http",
host = helpers.mock_upstream_host,
port = helpers.get_proxy_port(),
})
if not ok then
assert.is_nil(err)
end
-- Then send using `request`, supplying a path and `Host` header instead of a
-- full URI.
local res, err = httpc:request({
path = "/gemini/llm/v1/chat/functions/good",
body = pl_file.read("spec/fixtures/ai-proxy/openai/llm-v1-chat/requests/good-stream-with-functions.json"),
headers = {
["content-type"] = "application/json",
["accept"] = "application/json",
},
})
if not res then
assert.is_nil(err)
end
assert.equal(200, res.status)
local reader = res.body_reader
local buffer_size = 35536
-- extract event
local func_name
local func_args
repeat
-- receive next chunk
local buffer, err = reader(buffer_size)
if err then
assert.is_falsy(err and err ~= "closed")
end
if buffer then
-- we need to rip each message from this chunk
for s in buffer:gmatch("[^\r\n]+") do
local s_copy = string.sub(s,7)
s = cjson.decode(s_copy)
if s and s.choices then
func_name = s.choices[1].delta.tool_calls[1]['function'].name
func_args = s.choices[1].delta.tool_calls[1]['function'].arguments
end
end
end
until not buffer
assert.equal("add", func_name)
-- function args ordering can be randomised by kong during cjson.encode
local args = cjson.decode(func_args)
assert.equal(2, args.a)
assert.equal(12, args.b)
-- to verify not enable `kong.service.request.enable_buffering()`
assert.logfile().has.no.line("/kong_buffered_http", true, 10)
end)
it("good stream request bedrock with function calls", function()
local httpc = http.new()
local ok, err, _ = httpc:connect({
scheme = "http",
host = helpers.mock_upstream_host,
port = helpers.get_proxy_port(),
})
if not ok then
assert.is_nil(err)
end
-- Then send using `request`, supplying a path and `Host` header instead of a
-- full URI.
local res, err = httpc:request({
path = "/bedrock/llm/v1/chat/functions/good",
body = pl_file.read("spec/fixtures/ai-proxy/openai/llm-v1-chat/requests/good-stream-with-functions.json"),
headers = {
["content-type"] = "application/json",
["accept"] = "application/json",
},
})
if not res then
assert.is_nil(err)
end
local reader = res.body_reader
local buffer_size = 35536
local buf = require("string.buffer").new()
-- extract event
local func_name
repeat
-- receive next chunk
local buffer, err = reader(buffer_size)
if err then
assert.is_falsy(err and err ~= "closed")
end
if buffer then
-- we need to rip each message from this chunk
for s in buffer:gmatch("[^\r\n]+") do
local s_copy = string.sub(s,7)
s = cjson.decode(s_copy)
if s
and s.choices
and #s.choices > 0
and s.choices[1].delta
and s.choices[1].delta.tool_calls
then
if s.choices[1].delta.tool_calls[1]['function'].name then
func_name = s.choices[1].delta.tool_calls[1]['function'].name
end
if s.choices[1].delta.tool_calls[1]['function'].arguments then
buf:put(s.choices[1].delta.tool_calls[1]['function'].arguments)
end
end
end
end
until not buffer
assert.equal("add", func_name)
-- function args ordering can be randomised by kong during cjson.encode
local args = cjson.decode(buf:tostring())
assert.equal(2, args.a)
assert.equal(12, args.b)
-- to verify not enable `kong.service.request.enable_buffering()`
assert.logfile().has.no.line("/kong_buffered_http", true, 10)
end)
it("good stream request gemini with function calls and native format", function()
local httpc = http.new()
local ok, err, _ = httpc:connect({
scheme = "http",
host = helpers.mock_upstream_host,
port = helpers.get_proxy_port(),
})
if not ok then
assert.is_nil(err)
end
-- Then send using `request`, supplying a path and `Host` header instead of a
-- full URI.
local res, err = httpc:request({
path = "/gemini-native/llm/v1/chat/functions/good/models/gemini-1.5-flash:streamGenerateContent",
body = pl_file.read("spec/fixtures/ai-proxy/native/gemini/request/with-functions-and-chatter.json"),
headers = {
["content-type"] = "application/json",
["accept"] = "application/json",
},
})
if not res then
assert.is_nil(err)
end
assert.equal(200, res.status)
assert.same("gemini/gemini-1.5-flash", res.headers["X-Kong-LLM-Model"])
local reader = res.body_reader
local buffer_size = 35536
-- extract event
local found_marker
repeat
-- receive next chunk
local buffer, err = reader(buffer_size)
if err then
assert.is_falsy(err and err ~= "closed")
end
if buffer then
-- we need to rip each message from this chunk
for s in buffer:gmatch("[^\r\n]+") do
found_marker = found_marker or not not s:match("safetyRatings") -- something openai doesn't have
end
end
until not buffer
assert.truthy(found_marker, "didn't find gemini native response marker, is it being transformer?")
-- to verify not enable `kong.service.request.enable_buffering()`
assert.logfile().has.no.line("/kong_buffered_http", true, 10)
end)
it("good stream request bedrock with function calls and native format", function()
local httpc = http.new()
local ok, err, _ = httpc:connect({
scheme = "http",
host = helpers.mock_upstream_host,
port = helpers.get_proxy_port(),
})
if not ok then
assert.is_nil(err)
end
-- Then send using `request`, supplying a path and `Host` header instead of a
-- full URI.
local res, err = httpc:request({
path = "/bedrock-native/llm/v1/chat/functions/good/model/aws-titan-v1:0/converse-stream",
body = pl_file.read("spec/fixtures/ai-proxy/native/bedrock/request/with-functions-and-chatter.json"),
headers = {
["content-type"] = "application/json",
["accept"] = "application/json",
},
})
if not res then
assert.is_nil(err)
end
assert.same("bedrock/aws-titan-v1:0", res.headers["X-Kong-LLM-Model"])
local reader = res.body_reader
local buffer_size = 35536
-- extract event
local found_marker
repeat
-- receive next chunk
local buffer, err = reader(buffer_size)
if err then
assert.is_falsy(err and err ~= "closed")
end
if buffer then
-- we need to rip each message from this chunk
for s in buffer:gmatch("[^\r\n]+") do
found_marker = found_marker or not not s:match("contentBlockIndex") -- something openai doesn't have
end
end
until not buffer
assert.truthy(found_marker, "didn't find bedrock native response marker, is it being transformer?")
-- to verify not enable `kong.service.request.enable_buffering()`
assert.logfile().has.no.line("/kong_buffered_http", true, 10)
end)
end)
end)
end