1398 lines
54 KiB
Lua
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
|