1
This commit is contained in:
@@ -59,21 +59,262 @@ server {
|
||||
|
||||
location = /v1/chat/completions {
|
||||
client_body_buffer_size 16k;
|
||||
subrequest_output_buffer_size 8m;
|
||||
|
||||
content_by_lua_block {
|
||||
local backend = "@ai_sggai_site_backend"
|
||||
local max_body_bytes = 1024
|
||||
local cjson = require "cjson.safe"
|
||||
local json_null = cjson.null
|
||||
|
||||
local function write_json(status, value)
|
||||
local encoded = cjson.encode(value)
|
||||
if not encoded then
|
||||
encoded = '{"error":{"message":"failed to encode gateway response","type":"gateway_error"}}'
|
||||
status = ngx.HTTP_BAD_GATEWAY
|
||||
end
|
||||
|
||||
ngx.status = status
|
||||
ngx.header["Content-Type"] = "application/json; charset=utf-8"
|
||||
ngx.say(encoded)
|
||||
return ngx.exit(status)
|
||||
end
|
||||
|
||||
local function ensure_choice(choices, index)
|
||||
index = tonumber(index) or 0
|
||||
local key = index + 1
|
||||
|
||||
if not choices[key] then
|
||||
choices[key] = {
|
||||
index = index,
|
||||
role = nil,
|
||||
content = {},
|
||||
reasoning_content = {},
|
||||
finish_reason = nil,
|
||||
tool_calls = {}
|
||||
}
|
||||
end
|
||||
|
||||
return choices[key]
|
||||
end
|
||||
|
||||
local function append_tool_call(choice_state, tool_call_delta)
|
||||
if type(tool_call_delta) ~= "table" then
|
||||
return
|
||||
end
|
||||
|
||||
local tool_index = tonumber(tool_call_delta.index) or #choice_state.tool_calls
|
||||
local key = tool_index + 1
|
||||
local tool_state = choice_state.tool_calls[key]
|
||||
|
||||
if not tool_state then
|
||||
tool_state = {
|
||||
index = tool_index,
|
||||
id = nil,
|
||||
type = nil,
|
||||
function_name = {},
|
||||
function_arguments = {}
|
||||
}
|
||||
choice_state.tool_calls[key] = tool_state
|
||||
end
|
||||
|
||||
if type(tool_call_delta.id) == "string" then
|
||||
tool_state.id = tool_call_delta.id
|
||||
end
|
||||
|
||||
if type(tool_call_delta.type) == "string" then
|
||||
tool_state.type = tool_call_delta.type
|
||||
end
|
||||
|
||||
local fn = tool_call_delta["function"]
|
||||
if type(fn) == "table" then
|
||||
if type(fn.name) == "string" then
|
||||
tool_state.function_name[#tool_state.function_name + 1] = fn.name
|
||||
end
|
||||
|
||||
if type(fn.arguments) == "string" then
|
||||
tool_state.function_arguments[#tool_state.function_arguments + 1] = fn.arguments
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function flush_sse_data(data, state)
|
||||
if data == "[DONE]" then
|
||||
return true, true
|
||||
end
|
||||
|
||||
local chunk, decode_err = cjson.decode(data)
|
||||
if type(chunk) ~= "table" then
|
||||
return nil, "bad upstream SSE JSON: " .. (decode_err or data)
|
||||
end
|
||||
|
||||
if type(chunk.id) == "string" then
|
||||
state.id = chunk.id
|
||||
end
|
||||
|
||||
if type(chunk.created) == "number" then
|
||||
state.created = chunk.created
|
||||
end
|
||||
|
||||
if type(chunk.model) == "string" then
|
||||
state.model = chunk.model
|
||||
end
|
||||
|
||||
if type(chunk.system_fingerprint) == "string" then
|
||||
state.system_fingerprint = chunk.system_fingerprint
|
||||
end
|
||||
|
||||
if type(chunk.usage) == "table" then
|
||||
state.usage = chunk.usage
|
||||
end
|
||||
|
||||
if type(chunk.choices) ~= "table" then
|
||||
return true, false
|
||||
end
|
||||
|
||||
for _, upstream_choice in ipairs(chunk.choices) do
|
||||
if type(upstream_choice) == "table" then
|
||||
local choice_state = ensure_choice(state.choices, upstream_choice.index)
|
||||
local delta = upstream_choice.delta
|
||||
|
||||
if type(delta) == "table" then
|
||||
if type(delta.role) == "string" then
|
||||
choice_state.role = delta.role
|
||||
end
|
||||
|
||||
if type(delta.content) == "string" then
|
||||
choice_state.content[#choice_state.content + 1] = delta.content
|
||||
end
|
||||
|
||||
if type(delta.reasoning_content) == "string" then
|
||||
choice_state.reasoning_content[#choice_state.reasoning_content + 1] = delta.reasoning_content
|
||||
end
|
||||
|
||||
if type(delta.tool_calls) == "table" then
|
||||
for _, tool_call_delta in ipairs(delta.tool_calls) do
|
||||
append_tool_call(choice_state, tool_call_delta)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if upstream_choice.finish_reason ~= nil and upstream_choice.finish_reason ~= json_null then
|
||||
choice_state.finish_reason = upstream_choice.finish_reason
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return true, false
|
||||
end
|
||||
|
||||
local function parse_sse_body(sse_body, original_payload)
|
||||
local state = {
|
||||
id = nil,
|
||||
created = nil,
|
||||
model = original_payload.model,
|
||||
system_fingerprint = nil,
|
||||
usage = nil,
|
||||
choices = {}
|
||||
}
|
||||
local data_lines = {}
|
||||
|
||||
for raw_line in (sse_body .. "\n"):gmatch("([^\n]*)\n") do
|
||||
local line = raw_line:gsub("\r$", "")
|
||||
|
||||
if line == "" then
|
||||
if #data_lines > 0 then
|
||||
local ok, done_or_err = flush_sse_data(table.concat(data_lines, "\n"), state)
|
||||
data_lines = {}
|
||||
|
||||
if not ok then
|
||||
return nil, done_or_err
|
||||
end
|
||||
|
||||
if done_or_err == true then
|
||||
break
|
||||
end
|
||||
end
|
||||
else
|
||||
local data = line:match("^data:%s?(.*)$")
|
||||
if data then
|
||||
data_lines[#data_lines + 1] = data
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if #data_lines > 0 then
|
||||
local ok, done_or_err = flush_sse_data(table.concat(data_lines, "\n"), state)
|
||||
if not ok then
|
||||
return nil, done_or_err
|
||||
end
|
||||
end
|
||||
|
||||
local choices = {}
|
||||
for _, choice_state in ipairs(state.choices) do
|
||||
local content = table.concat(choice_state.content)
|
||||
local message = {
|
||||
role = choice_state.role or "assistant",
|
||||
content = content
|
||||
}
|
||||
local tool_calls = {}
|
||||
|
||||
if #choice_state.reasoning_content > 0 then
|
||||
message.reasoning_content = table.concat(choice_state.reasoning_content)
|
||||
end
|
||||
|
||||
for _, tool_state in ipairs(choice_state.tool_calls) do
|
||||
tool_calls[#tool_calls + 1] = {
|
||||
id = tool_state.id,
|
||||
type = tool_state.type or "function",
|
||||
["function"] = {
|
||||
name = table.concat(tool_state.function_name),
|
||||
arguments = table.concat(tool_state.function_arguments)
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
if #tool_calls > 0 then
|
||||
message.tool_calls = tool_calls
|
||||
if content == "" then
|
||||
message.content = json_null
|
||||
end
|
||||
end
|
||||
|
||||
choices[#choices + 1] = {
|
||||
index = choice_state.index,
|
||||
message = message,
|
||||
finish_reason = choice_state.finish_reason or "stop"
|
||||
}
|
||||
end
|
||||
|
||||
if #choices == 0 then
|
||||
return nil, "upstream stream response did not contain choices"
|
||||
end
|
||||
|
||||
local completion = {
|
||||
id = state.id or ("chatcmpl-gateway-" .. ngx.now()),
|
||||
object = "chat.completion",
|
||||
created = state.created or ngx.time(),
|
||||
model = state.model,
|
||||
choices = choices
|
||||
}
|
||||
|
||||
if state.usage then
|
||||
completion.usage = state.usage
|
||||
end
|
||||
|
||||
if state.system_fingerprint then
|
||||
completion.system_fingerprint = state.system_fingerprint
|
||||
end
|
||||
|
||||
return completion
|
||||
end
|
||||
|
||||
if ngx.req.get_method() ~= "POST" then
|
||||
return ngx.exec(backend)
|
||||
end
|
||||
|
||||
local content_length = tonumber(ngx.var.http_content_length)
|
||||
if not content_length then
|
||||
return ngx.exec(backend)
|
||||
end
|
||||
|
||||
if content_length > max_body_bytes then
|
||||
if not content_length or content_length > max_body_bytes then
|
||||
return ngx.exec(backend)
|
||||
end
|
||||
|
||||
@@ -84,59 +325,112 @@ server {
|
||||
return ngx.exec(backend)
|
||||
end
|
||||
|
||||
local cjson = require "cjson.safe"
|
||||
local short_post_log = {
|
||||
local payload = cjson.decode(body)
|
||||
if type(payload) ~= "table" or payload.stream ~= false then
|
||||
return ngx.exec(backend)
|
||||
end
|
||||
|
||||
payload.stream = true
|
||||
|
||||
local upstream_body, encode_err = cjson.encode(payload)
|
||||
if not upstream_body then
|
||||
ngx.log(ngx.ERR, "[ai.sggai.site] failed to encode upstream stream request: ", encode_err or "unknown")
|
||||
return write_json(ngx.HTTP_BAD_GATEWAY, {
|
||||
error = {
|
||||
message = "failed to encode upstream stream request",
|
||||
type = "gateway_error"
|
||||
}
|
||||
})
|
||||
end
|
||||
|
||||
ngx.log(ngx.NOTICE, "[ai.sggai.site] bridge short non-stream POST as upstream stream: ", cjson.encode({
|
||||
method = ngx.req.get_method(),
|
||||
uri = ngx.var.request_uri,
|
||||
content_length = content_length,
|
||||
content_type = ngx.var.http_content_type,
|
||||
user_agent = ngx.var.http_user_agent,
|
||||
body = body
|
||||
}) or body)
|
||||
|
||||
local upstream_res = ngx.location.capture("/__ai_sggai_stream_bridge_backend", {
|
||||
method = ngx.HTTP_POST,
|
||||
body = upstream_body,
|
||||
args = ngx.var.args or ""
|
||||
})
|
||||
|
||||
if not upstream_res then
|
||||
ngx.log(ngx.ERR, "[ai.sggai.site] upstream stream subrequest failed")
|
||||
return write_json(ngx.HTTP_BAD_GATEWAY, {
|
||||
error = {
|
||||
message = "upstream stream subrequest failed",
|
||||
type = "gateway_error"
|
||||
}
|
||||
ngx.log(ngx.NOTICE, "[ai.sggai.site] short POST request: ", cjson.encode(short_post_log) or body)
|
||||
|
||||
local payload = cjson.decode(body)
|
||||
if type(payload) ~= "table" or payload.stream ~= false then
|
||||
return ngx.exec(backend)
|
||||
})
|
||||
end
|
||||
|
||||
if type(payload.messages) ~= "table" then
|
||||
return ngx.exec(backend)
|
||||
if upstream_res.status < 200 or upstream_res.status >= 300 then
|
||||
ngx.status = upstream_res.status
|
||||
ngx.header["Content-Type"] = (upstream_res.header and upstream_res.header["Content-Type"]) or "application/json; charset=utf-8"
|
||||
ngx.print(upstream_res.body or "")
|
||||
return ngx.exit(upstream_res.status)
|
||||
end
|
||||
|
||||
local has_hi_content = false
|
||||
|
||||
for _, message in ipairs(payload.messages) do
|
||||
if type(message) == "table" and message.content == "hi" then
|
||||
has_hi_content = true
|
||||
break
|
||||
end
|
||||
local plain_completion = cjson.decode(upstream_res.body or "")
|
||||
if type(plain_completion) == "table" and type(plain_completion.choices) == "table" then
|
||||
return write_json(ngx.HTTP_OK, plain_completion)
|
||||
end
|
||||
|
||||
if not has_hi_content then
|
||||
return ngx.exec(backend)
|
||||
end
|
||||
|
||||
ngx.status = ngx.HTTP_OK
|
||||
ngx.header["Content-Type"] = "application/json; charset=utf-8"
|
||||
ngx.say([[{
|
||||
"id": "chatcmpl-mock",
|
||||
"object": "chat.completion",
|
||||
"created": 1716030000,
|
||||
"model": "xxx",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": { "role": "assistant", "content": "ok" },
|
||||
"finish_reason": "stop"
|
||||
local completion, parse_err = parse_sse_body(upstream_res.body or "", payload)
|
||||
if not completion then
|
||||
ngx.log(ngx.ERR, "[ai.sggai.site] failed to parse upstream stream response: ", parse_err or "unknown", "; upstream_body=", (upstream_res.body or ""):sub(1, 2048))
|
||||
return write_json(ngx.HTTP_BAD_GATEWAY, {
|
||||
error = {
|
||||
message = "failed to parse upstream stream response",
|
||||
type = "gateway_error"
|
||||
}
|
||||
],
|
||||
"usage": { "prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2 }
|
||||
}]])
|
||||
return ngx.exit(ngx.HTTP_OK)
|
||||
})
|
||||
end
|
||||
|
||||
return write_json(ngx.HTTP_OK, completion)
|
||||
}
|
||||
}
|
||||
|
||||
location = /__ai_sggai_stream_bridge_backend {
|
||||
internal;
|
||||
proxy_buffering on;
|
||||
proxy_request_buffering on;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_set_header Content-Type "application/json";
|
||||
proxy_set_header Accept "text/event-stream";
|
||||
proxy_set_header User-Agent $http_user_agent;
|
||||
proxy_set_header Originator $http_originator;
|
||||
proxy_set_header Session_id $http_session_id;
|
||||
proxy_set_header X-Codex-Beta-Features $http_x_codex_beta_features;
|
||||
proxy_set_header X-Codex-Turn-Metadata $http_x_codex_turn_metadata;
|
||||
proxy_set_header X-Stainless-Arch $http_x_stainless_arch;
|
||||
proxy_set_header X-Stainless-Lang $http_x_stainless_lang;
|
||||
proxy_set_header X-Stainless-Os $http_x_stainless_os;
|
||||
proxy_set_header X-Stainless-Package-Version $http_x_stainless_package_version;
|
||||
proxy_set_header X-Stainless-Retry-Count $http_x_stainless_retry_count;
|
||||
proxy_set_header X-Stainless-Runtime $http_x_stainless_runtime;
|
||||
proxy_set_header X-Stainless-Runtime-Version $http_x_stainless_runtime_version;
|
||||
proxy_set_header X-Stainless-Timeout $http_x_stainless_timeout;
|
||||
proxy_set_header X-App $http_x_app;
|
||||
proxy_set_header Anthropic-Beta $http_anthropic_beta;
|
||||
proxy_set_header Anthropic-Dangerous-Direct-Browser-Access $http_anthropic_dangerous_direct_browser_access;
|
||||
proxy_set_header Anthropic-Version $http_anthropic_version;
|
||||
proxy_set_header Accept-Encoding "";
|
||||
proxy_set_header X-Real-IP "";
|
||||
proxy_set_header X-Forwarded-For "";
|
||||
proxy_set_header X-Forwarded-Proto "";
|
||||
proxy_set_header X-Forwarded-Host "";
|
||||
proxy_set_header X-Forwarded-Port "";
|
||||
proxy_set_header Connection "";
|
||||
proxy_pass http://10.1.0.1:3001/v1/chat/completions;
|
||||
}
|
||||
|
||||
location @ai_sggai_site_backend {
|
||||
proxy_pass http://10.1.0.1:3001;
|
||||
}
|
||||
|
||||
147
conf/conf.d/ai.sggai.site.conf.back
Normal file
147
conf/conf.d/ai.sggai.site.conf.back
Normal file
@@ -0,0 +1,147 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen 443 ssl;
|
||||
server_name ai.sggai.site;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/ai.sggai.site/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/ai.sggai.site/privkey.pem;
|
||||
|
||||
error_log /usr/local/openresty/nginx/logs/ai.sggai.site.error.log notice;
|
||||
error_log /dev/stderr notice;
|
||||
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /var/www;
|
||||
default_type text/plain;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
client_max_body_size 200m;
|
||||
|
||||
gzip off;
|
||||
gunzip off;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_set_header Content-Type $http_content_type;
|
||||
proxy_set_header Accept $http_accept;
|
||||
proxy_set_header User-Agent $http_user_agent;
|
||||
proxy_set_header Originator $http_originator;
|
||||
proxy_set_header Session_id $http_session_id;
|
||||
proxy_set_header X-Codex-Beta-Features $http_x_codex_beta_features;
|
||||
proxy_set_header X-Codex-Turn-Metadata $http_x_codex_turn_metadata;
|
||||
proxy_set_header X-Stainless-Arch $http_x_stainless_arch;
|
||||
proxy_set_header X-Stainless-Lang $http_x_stainless_lang;
|
||||
proxy_set_header X-Stainless-Os $http_x_stainless_os;
|
||||
proxy_set_header X-Stainless-Package-Version $http_x_stainless_package_version;
|
||||
proxy_set_header X-Stainless-Retry-Count $http_x_stainless_retry_count;
|
||||
proxy_set_header X-Stainless-Runtime $http_x_stainless_runtime;
|
||||
proxy_set_header X-Stainless-Runtime-Version $http_x_stainless_runtime_version;
|
||||
proxy_set_header X-Stainless-Timeout $http_x_stainless_timeout;
|
||||
proxy_set_header X-App $http_x_app;
|
||||
proxy_set_header Anthropic-Beta $http_anthropic_beta;
|
||||
proxy_set_header Anthropic-Dangerous-Direct-Browser-Access $http_anthropic_dangerous_direct_browser_access;
|
||||
proxy_set_header Anthropic-Version $http_anthropic_version;
|
||||
proxy_set_header Accept-Encoding "";
|
||||
proxy_set_header X-Real-IP "";
|
||||
proxy_set_header X-Forwarded-For "";
|
||||
proxy_set_header X-Forwarded-Proto "";
|
||||
proxy_set_header X-Forwarded-Host "";
|
||||
proxy_set_header X-Forwarded-Port "";
|
||||
proxy_set_header Connection "";
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_cache_bypass 1;
|
||||
proxy_connect_timeout 600s;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
|
||||
location = /v1/chat/completions {
|
||||
client_body_buffer_size 16k;
|
||||
|
||||
content_by_lua_block {
|
||||
local backend = "@ai_sggai_site_backend"
|
||||
local max_body_bytes = 1024
|
||||
|
||||
if ngx.req.get_method() ~= "POST" then
|
||||
return ngx.exec(backend)
|
||||
end
|
||||
|
||||
local content_length = tonumber(ngx.var.http_content_length)
|
||||
if not content_length then
|
||||
return ngx.exec(backend)
|
||||
end
|
||||
|
||||
if content_length > max_body_bytes then
|
||||
return ngx.exec(backend)
|
||||
end
|
||||
|
||||
ngx.req.read_body()
|
||||
|
||||
local body = ngx.req.get_body_data()
|
||||
if not body or #body > max_body_bytes then
|
||||
return ngx.exec(backend)
|
||||
end
|
||||
|
||||
local cjson = require "cjson.safe"
|
||||
local short_post_log = {
|
||||
method = ngx.req.get_method(),
|
||||
uri = ngx.var.request_uri,
|
||||
content_length = content_length,
|
||||
content_type = ngx.var.http_content_type,
|
||||
user_agent = ngx.var.http_user_agent,
|
||||
body = body
|
||||
}
|
||||
ngx.log(ngx.NOTICE, "[ai.sggai.site] short POST request: ", cjson.encode(short_post_log) or body)
|
||||
|
||||
local payload = cjson.decode(body)
|
||||
if type(payload) ~= "table" or payload.stream ~= false then
|
||||
return ngx.exec(backend)
|
||||
end
|
||||
|
||||
if type(payload.messages) ~= "table" then
|
||||
return ngx.exec(backend)
|
||||
end
|
||||
|
||||
local has_hi_content = false
|
||||
|
||||
for _, message in ipairs(payload.messages) do
|
||||
if type(message) == "table" and message.content == "hi" then
|
||||
has_hi_content = true
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if not has_hi_content then
|
||||
return ngx.exec(backend)
|
||||
end
|
||||
|
||||
ngx.status = ngx.HTTP_OK
|
||||
ngx.header["Content-Type"] = "application/json; charset=utf-8"
|
||||
ngx.say([[{
|
||||
"id": "chatcmpl-mock",
|
||||
"object": "chat.completion",
|
||||
"created": 1716030000,
|
||||
"model": "xxx",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": { "role": "assistant", "content": "ok" },
|
||||
"finish_reason": "stop"
|
||||
}
|
||||
],
|
||||
"usage": { "prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2 }
|
||||
}]])
|
||||
return ngx.exit(ngx.HTTP_OK)
|
||||
}
|
||||
}
|
||||
|
||||
location @ai_sggai_site_backend {
|
||||
proxy_pass http://10.1.0.1:3001;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://10.1.0.1:3001;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user