feat: add scoped read-only audio preview and isolated Dify transport

This commit is contained in:
2026-10-09 16:10:59 +08:00
parent 27772bec61
commit 4d0af7f3f4
36 changed files with 1517 additions and 68 deletions
+165
View File
@@ -0,0 +1,165 @@
"""Fixed-upstream ASR gateway: stdlib only, no retry, no body/header logging."""
from __future__ import annotations
import hmac
import http.client
import json
import os
import socket
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
MAX_BODY = 30 * 1024 * 1024 + 65536 # Dify accepts 30 MiB file; multipart allowance.
MAX_RESPONSE = 4 * 1024 * 1024
UPSTREAM_HOST = 'followup-audio-asr'
UPSTREAM_PORT = 8000
RATE_PER_MINUTE = 120
RATE_BURST = 4
class Gate:
def __init__(self, rate=RATE_PER_MINUTE, burst=RATE_BURST):
self.rate, self.burst = rate, burst
self.tokens, self.at = float(burst), time.monotonic()
self.lock = threading.Lock()
self.active = threading.BoundedSemaphore(1)
def take(self):
with self.lock:
now = time.monotonic()
self.tokens = min(float(self.burst), self.tokens + (now - self.at) * self.rate / 60)
self.at = now
if self.tokens < 1:
return False
self.tokens -= 1
return True
class Server(ThreadingHTTPServer):
daemon_threads = True
allow_reuse_address = True
request_queue_size = 8
def __init__(self, address, handler, *, key, upstream=(UPSTREAM_HOST, UPSTREAM_PORT), rate=RATE_PER_MINUTE, burst=RATE_BURST, timeout=180):
super().__init__(address, handler)
self.key, self.upstream, self.timeout = key, upstream, timeout
self.gate = Gate(rate, burst)
self.connections = threading.BoundedSemaphore(8)
def process_request(self, request, client_address):
if not self.connections.acquire(blocking=False):
try:
request.sendall(b'HTTP/1.1 429 Too Many Requests\r\nContent-Length: 0\r\nConnection: close\r\n\r\n')
finally:
self.shutdown_request(request)
return
try:
super().process_request(request, client_address)
except BaseException:
self.connections.release()
raise
def process_request_thread(self, request, client_address):
try:
super().process_request_thread(request, client_address)
finally:
self.connections.release()
def handle_error(self, request, client_address):
# Deliberately suppress tracebacks/request fragments from client errors.
print(json.dumps({'event': 'client_error'}), flush=True)
class Handler(BaseHTTPRequestHandler):
protocol_version = 'HTTP/1.1'
server_version = 'FollowupASRBridge/1'
sys_version = ''
def setup(self):
super().setup()
self.connection.settimeout(30)
def log_message(self, format, *args):
pass
def reply(self, code, body, content_type='application/json'):
self.close_connection = True
self.send_response(code)
self.send_header('Content-Type', content_type)
self.send_header('Content-Length', str(len(body)))
self.send_header('Connection', 'close')
self.send_header('Cache-Control', 'no-store')
if code == 429:
self.send_header('Retry-After', '1')
self.end_headers()
self.wfile.write(body)
def error_json(self, code, label):
self.reply(code, json.dumps({'error': label}).encode())
def authorized(self):
supplied = self.headers.get('Authorization', '')
return hmac.compare_digest(supplied.encode(), b'Bearer ' + self.server.key)
def upstream_call(self, method, path, body=None, content_type=None, health=False):
conn = http.client.HTTPConnection(*self.server.upstream, timeout=3 if health else self.server.timeout)
try:
headers = {} if health else {'Authorization': 'Bearer ' + self.server.key.decode()}
if content_type:
headers['Content-Type'] = content_type
conn.request(method, path, body=body, headers=headers)
response = conn.getresponse()
result = response.read(MAX_RESPONSE + 1)
if len(result) > MAX_RESPONSE:
return self.error_json(502, 'upstream_response_too_large')
if health:
return self.reply(200 if response.status == 200 else 503, b'{"status":"ok"}' if response.status == 200 else b'{"status":"unhealthy"}')
self.reply(response.status, result, response.getheader('Content-Type', 'application/json'))
except (OSError, http.client.HTTPException, TimeoutError):
self.error_json(503 if health else 502, 'upstream_unavailable')
finally:
conn.close()
def do_GET(self):
if self.path == '/health':
return self.upstream_call('GET', '/health', health=True)
if self.path != '/v1/models':
return self.error_json(404, 'not_found')
if not self.authorized():
return self.error_json(401, 'unauthorized')
return self.upstream_call('GET', '/v1/models')
def do_POST(self):
if self.path != '/v1/audio/transcriptions':
return self.error_json(404, 'not_found')
if not self.authorized():
return self.error_json(401, 'unauthorized')
if self.headers.get('Transfer-Encoding') or not self.headers.get('Content-Length', '').isdigit():
return self.error_json(411, 'content_length_required')
length = int(self.headers['Content-Length'])
if length > MAX_BODY:
return self.error_json(413, 'body_too_large')
if length <= 0:
return self.error_json(400, 'empty_body')
content_type = self.headers.get('Content-Type', '')
if not content_type.startswith('multipart/form-data;') or 'boundary=' not in content_type:
return self.error_json(415, 'multipart_required')
if not self.server.gate.active.acquire(blocking=False):
return self.error_json(429, 'asr_busy')
try:
if not self.server.gate.take():
return self.error_json(429, 'rate_limit')
try:
body = self.rfile.read(length)
except (OSError, socket.timeout):
return self.error_json(408, 'upload_timeout')
if len(body) != length:
return self.error_json(400, 'incomplete_body')
return self.upstream_call('POST', '/v1/audio/transcriptions', body, content_type)
finally:
self.server.gate.active.release()
if __name__ == '__main__':
key = Path('/run/secrets/asr-key').read_bytes().strip()
if len(key) < 16 or b'\n' in key or b'\r' in key:
raise SystemExit('invalid_secret_file')
print(json.dumps({'event': 'started', 'rate_per_minute': RATE_PER_MINUTE, 'inflight': 1, 'host_ports': False}), flush=True)
Server(('0.0.0.0', 8080), Handler, key=key).serve_forever()