| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314 |
- """Standalone SSE diagnostic using http.client and keep-alive.
- Run: .venv/bin/python scripts/test_deepseek_proxy_stream.py
- No OpenAI SDK path joining, retries, or response cache is used.
- """
- from __future__ import annotations
- import argparse
- from http.client import HTTPConnection, HTTPSConnection, IncompleteRead
- import json
- import os
- import re
- import socket
- import sys
- import time
- from pathlib import Path
- from urllib.parse import parse_qsl, quote, quote_plus, urlsplit, urlunsplit
- def load_env_values(path: Path) -> dict[str, str]:
- """Read just the three diagnostic settings, with no third-party dependency."""
- values = {}
- if not path.is_file():
- return values
- for line in path.read_text(encoding="utf-8-sig").splitlines():
- line = line.strip()
- if line.startswith("export "):
- line = line[7:]
- if not line or line.startswith("#") or "=" not in line:
- continue
- key, value = line.split("=", 1)
- key, value = key.strip(), value.strip()
- if key not in {"DEEPSEEK_BASE_URL", "DEEPSEEK_API_KEY", "DEEPSEEK_MODEL"}:
- continue
- if value.startswith(("'", '"')):
- quote_char = value[0]
- end = value.find(quote_char, 1)
- if end < 0 or (value[end + 1:].strip() and not value[end + 1:].strip().startswith("#")):
- raise ValueError("invalid_env_quoting")
- value = value[1:end]
- else:
- value = value.split(" #", 1)[0].strip()
- values[key] = value
- return values
- def _redact(value: str, secrets: list[str]) -> str:
- for secret in sorted(set(secrets), key=len, reverse=True):
- if secret:
- for variant in (secret, quote(secret, safe=""), quote_plus(secret)):
- value = value.replace(variant, "[REDACTED]")
- value = re.sub(r"(?i)(proxyToken[\"'\s]*[:=][\"'\s]*)[^&\s\"'<>]+", r"\1[REDACTED]", value)
- return re.sub(r"(?i)(Bearer\s+)\S+", r"\1[REDACTED]", value)
- def probe(*, timeout: float = 30, method: str = "POST") -> dict:
- """Return safe diagnostics; environment variables override the root .env."""
- if method != "POST":
- return {"ok": False, "code": "invalid_method"}
- env_file = Path(__file__).resolve().parents[1] / ".env"
- try:
- values = load_env_values(env_file)
- except (OSError, ValueError):
- return {"ok": False, "code": "invalid_env_file", "hint": ".env无法读取或引号格式无效"}
- values.update({key: os.environ[key] for key in (
- "DEEPSEEK_BASE_URL", "DEEPSEEK_API_KEY", "DEEPSEEK_MODEL"
- ) if key in os.environ})
- url = values.get("DEEPSEEK_BASE_URL", "").strip()
- api_key = values.get("DEEPSEEK_API_KEY", "").strip()
- model = values.get("DEEPSEEK_MODEL", "deepseek-flash").strip()
- result = {
- "ok": False,
- "env_file_exists": env_file.is_file(),
- "api_key_configured": bool(api_key),
- "environment_proxy_configured": any(os.environ.get(k) for k in (
- "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"
- )),
- "use_environment_proxy": False,
- "transport": "http.client",
- "request_connection": "keep-alive",
- "stream": True,
- }
- secrets = [api_key]
- try:
- parts = urlsplit(url)
- params = parse_qsl(parts.query, keep_blank_values=True)
- secrets.extend(value for key, value in params if key.lower() == "proxytoken")
- if (parts.scheme not in ("http", "https") or not parts.hostname
- or parts.username or parts.password or parts.fragment
- or any(char.isspace() for char in url)):
- raise ValueError
- _ = parts.port
- if parts.path.rstrip("/").endswith("/chat/completions"):
- parts = parts._replace(path=parts.path.rstrip("/"))
- url = urlunsplit(parts)
- except ValueError:
- return {**result, "code": "invalid_base_url", "hint": "DEEPSEEK_BASE_URL须为单行完整HTTP(S)代理URL"}
- result.update(endpoint=f"{parts.scheme}://{parts.netloc}{parts.path}",
- query_keys=sorted({key for key, _ in params}),
- model=_redact(model, secrets), method=method,
- chat_payload_sent=method == "POST")
- query = dict(params)
- if query.get("servertype") != "deepseek" or not query.get("proxyToken"):
- return {**result, "code": "missing_proxy_params", "hint": "URL须包含servertype=deepseek及非空proxyToken,注意参数大小写"}
- payload = {
- "model": model,
- "messages": [{"role": "user", "content": "1+1等于多少"}],
- "max_tokens": 128,
- "stream": True,
- }
- headers = {"Accept": "*/*", "Connection": "keep-alive"}
- if method == "POST":
- headers["Content-Type"] = "application/json"
- if api_key:
- headers["Authorization"] = f"Bearer {api_key}"
- # GET probes the documented gateway URL only. Do not guess how a custom
- # gateway encodes upstream chat requests in its query parameters.
- request_body = json.dumps(payload, ensure_ascii=False).encode("utf-8") if method == "POST" else None
- started = time.perf_counter()
- connection = None
- try:
- connection_type = HTTPSConnection if parts.scheme == "https" else HTTPConnection
- connection = connection_type(parts.hostname, parts.port, timeout=timeout)
- target = (parts.path or "/") + ("?" + parts.query if parts.query else "")
- # Direct connection: no environment proxy, redirects, or automatic retry.
- connection.request(method, target, body=request_body, headers=headers)
- response = connection.getresponse()
- with response:
- result["http_status"] = response.status
- result["http_ok"] = 200 <= response.status < 300
- result["content_type"] = _redact(response.headers.get("Content-Type", ""), secrets)
- result["transfer_encoding"] = _redact(response.headers.get("Transfer-Encoding", ""), secrets)[:120]
- length = response.headers.get("Content-Length", "")
- expected_length = int(length) if length.isascii() and length.isdecimal() else None
- result["content_length"] = expected_length
- if result['http_ok'] and result['content_type'].split(';')[0].strip().lower() == 'text/event-stream':
- read_sse(response, result, secrets, started, timeout)
- return result
- result["response_complete"] = True
- try:
- raw = response.read(1024 * 1024 + 1)
- except IncompleteRead as exc:
- # Malformed/unfinished chunked responses can contain the useful
- # gateway error text in partial; do not discard that evidence.
- raw = exc.partial
- result.update(response_complete=False, error_type="IncompleteRead")
- if (expected_length is not None and not result["transfer_encoding"]
- and len(raw) < expected_length):
- result.update(response_complete=False, error_type="ContentLengthMismatch")
- result["received_bytes"] = len(raw)
- if len(raw) > 1024 * 1024:
- result["code"] = "response_too_large"
- return result
- body = raw.decode("utf-8", errors="replace")
- result["response_preview"] = _redact(body, secrets)[:1200]
- if "authentication fails" in body.lower() and "governor" in body.lower():
- result.update(code="proxy_authentication_failed", hint="代理已响应但鉴权失败,请核对参数大小写、token及代理权限")
- elif not 200 <= result["http_status"] < 300:
- result["code"] = "http_error"
- elif not result["response_complete"]:
- result["code"] = "response_incomplete"
- else:
- try:
- data = json.loads(body)
- content = data["choices"][0]["message"]["content"]
- if not isinstance(content, str) or not content.strip():
- raise ValueError
- result.update(code="unexpected_response", hint="请求stream=true,但返回普通JSON;代理未返回SSE流")
- except (ValueError, KeyError, TypeError, IndexError):
- result.update(code="unexpected_response", hint="HTTP成功但不是含非空choices[0].message.content的聊天响应;需核对代理返回协议")
- if not result["response_complete"]:
- result["hint"] = (
- f"已收到HTTP {result['http_status']}响应头,但响应体传输不完整;"
- + ("未取得可解析的正文。" if not raw else "response_preview为已收到的脱敏片段。")
- + "HTTP 200不代表聊天成功,请代理同事检查重复Transfer-Encoding、分块编码及结束标记"
- )
- except (TimeoutError, socket.timeout):
- result["code"] = "timeout"
- except OSError as exc:
- result["code"] = "connection_failed"
- result["error_type"] = type(exc).__name__
- except Exception as exc:
- # Do not expose exceptions containing the complete URL or credentials.
- result.update(code="request_failed", error_type=type(exc).__name__)
- finally:
- if connection is not None:
- connection.close()
- result["elapsed_seconds"] = round(time.perf_counter() - started, 3)
- return result
- def read_sse(response, result, secrets, started, timeout):
- """Parse bounded SSE events incrementally, then verify HTTP framing to EOF."""
- result.update(response_complete=False, done_received=False, finish_reason=None,
- content_chunks=0, received_bytes=0, first_content_seconds=None,
- last_content_seconds=None, content_arrival_seconds=[])
- pending = b''
- event = []
- content = []
- invalid = False
- provider_error = False
- def dispatch():
- nonlocal invalid, provider_error
- if not event:
- return
- value = b'\n'.join(event).decode('utf-8')
- event.clear()
- if result['done_received']:
- invalid = True
- return
- if value == '[DONE]':
- result['done_received'] = True
- return
- data = json.loads(value)
- if not isinstance(data, dict):
- invalid = True
- return
- if 'error' in data:
- provider_error = True
- return
- choices = data.get('choices')
- if choices == []: # Optional usage frame.
- return
- if not isinstance(choices, list) or len(choices) != 1 or not isinstance(choices[0], dict):
- invalid = True
- return
- choice = choices[0]
- delta = choice.get('delta', {})
- if choice.get('index', 0) != 0 or not isinstance(delta, dict):
- invalid = True
- return
- text = delta.get('content')
- if text is not None and not isinstance(text, str):
- invalid = True
- return
- if text:
- if result['finish_reason'] is not None:
- invalid = True
- content.append(text)
- elapsed = round(time.perf_counter() - started, 3)
- result['content_chunks'] += 1
- if result['first_content_seconds'] is None:
- result['first_content_seconds'] = elapsed
- result['last_content_seconds'] = elapsed
- if len(result['content_arrival_seconds']) < 100:
- result['content_arrival_seconds'].append(elapsed)
- finish = choice.get('finish_reason')
- if finish is not None:
- if not isinstance(finish, str) or result['finish_reason'] is not None:
- invalid = True
- result['finish_reason'] = _redact(str(finish), secrets)[:80]
- try:
- while True:
- if time.perf_counter() - started > timeout:
- raise TimeoutError
- chunk = response.read1(4096)
- if not chunk:
- break
- result['received_bytes'] += len(chunk)
- if result['received_bytes'] > 1024 * 1024:
- result['code'] = 'response_too_large'
- return
- pending += chunk
- while b'\n' in pending:
- line, pending = pending.split(b'\n', 1)
- line = line.rstrip(b'\r')
- if not line:
- dispatch()
- elif line.startswith(b'data:'):
- value = line[5:]
- event.append(value[1:] if value.startswith(b' ') else value)
- if result['content_length'] is not None and not result['transfer_encoding'] and result['received_bytes'] != result['content_length']:
- raise IncompleteRead(b'')
- result['response_complete'] = True
- if pending or event:
- invalid = True
- if provider_error:
- result['code'] = 'provider_error'
- elif invalid:
- result['code'] = 'invalid_sse'
- elif not result['done_received'] or result['finish_reason'] is None:
- result['code'] = 'stream_incomplete'
- elif result['finish_reason'] != 'stop':
- result['code'] = 'incomplete_completion'
- elif not ''.join(content).strip():
- result['code'] = 'empty_stream_content'
- else:
- result.update(ok=True, code='stream_success')
- except IncompleteRead:
- result.update(code='response_incomplete', error_type='IncompleteRead')
- except (ValueError, UnicodeError):
- result['code'] = 'invalid_sse'
- finally:
- result['assistant_text'] = _redact(''.join(content), secrets)[:1200]
- result['hint'] = '到达时间仅供检查缓冲;单次短回答不能证明代理始终实时透传。成功需完整HTTP响应、非空正文、finish_reason=stop及[DONE]'
- def main() -> int:
- parser = argparse.ArgumentParser(description="读取.env,以keep-alive POST测试DeepSeek SSE流")
- parser.add_argument("--timeout", type=float, default=30, help="请求超时秒数,默认30")
- parser.add_argument("--method", choices=("POST",), default="POST")
- args = parser.parse_args()
- if not 0 < args.timeout <= 120:
- parser.error("--timeout须大于0且不超过120秒")
- result = probe(timeout=args.timeout, method=args.method)
- print(json.dumps(result, ensure_ascii=False, indent=2))
- return 0 if result["ok"] else 1
- if __name__ == "__main__":
- sys.exit(main())
|