1211 lines
46 KiB
Python
1211 lines
46 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Proxy: OpenAI /v1/chat/completions -> CommandCode /alpha/generate
|
|
|
|
Python port of server.js. Standard library only.
|
|
All settings live in config.json next to this file.
|
|
"""
|
|
|
|
import http.client
|
|
import json
|
|
import os
|
|
import platform
|
|
import random
|
|
import re
|
|
import signal
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
|
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
# -- config -------------------------------------------------------------------
|
|
|
|
DEFAULTS = {
|
|
'port': 3456,
|
|
'cc_version': '1.15.1',
|
|
'debug': False,
|
|
'upstream_host': 'api.commandcode.ai',
|
|
'upstream_path': '/alpha/generate',
|
|
'models_path': '/provider/v1/models',
|
|
'timeout_seconds': 300,
|
|
'log_file': 'proxy.log',
|
|
'max_body_bytes': 10 * 1024 * 1024,
|
|
'default_model': 'deepseek/deepseek-v4-pro',
|
|
'default_max_tokens': 32000,
|
|
'max_retries': 2,
|
|
'retry_max_delay_seconds': 60,
|
|
'fallback_models': [],
|
|
}
|
|
|
|
|
|
def load_config():
|
|
"""Shallow-merge config.json over DEFAULTS. Missing file is fine, bad JSON is not."""
|
|
path = os.path.join(BASE_DIR, 'config.json')
|
|
cfg = dict(DEFAULTS)
|
|
if not os.path.exists(path):
|
|
return cfg, 'config.json not found, using defaults'
|
|
try:
|
|
with open(path, 'r', encoding='utf-8') as f:
|
|
loaded = json.load(f)
|
|
except (ValueError, OSError) as e:
|
|
sys.stderr.write(f'failed to read config.json: {e}\n')
|
|
sys.exit(1)
|
|
if not isinstance(loaded, dict):
|
|
sys.stderr.write('config.json must contain a JSON object\n')
|
|
sys.exit(1)
|
|
for k, v in loaded.items():
|
|
if k in cfg:
|
|
cfg[k] = v
|
|
return cfg, None
|
|
|
|
|
|
CONFIG, CONFIG_NOTE = load_config()
|
|
|
|
PORT = int(CONFIG['port'])
|
|
HOST = CONFIG['upstream_host']
|
|
PATH = CONFIG['upstream_path']
|
|
MODELS_PATH = CONFIG['models_path']
|
|
CC_VERSION = CONFIG['cc_version']
|
|
DEBUG = bool(CONFIG['debug'])
|
|
TIMEOUT = float(CONFIG['timeout_seconds'])
|
|
MAX_BODY = int(CONFIG['max_body_bytes'])
|
|
DEFAULT_MODEL = CONFIG['default_model']
|
|
DEFAULT_MAX_TOKENS = int(CONFIG['default_max_tokens'])
|
|
MAX_RETRIES = int(CONFIG['max_retries'])
|
|
MAX_RETRY_DELAY = float(CONFIG['retry_max_delay_seconds'])
|
|
FALLBACK_MODELS = CONFIG['fallback_models']
|
|
|
|
# -- secret redaction ---------------------------------------------------------
|
|
# Upstream error bodies can echo the caller's credentials; they are both written to
|
|
# proxy.log and forwarded to the client, so scrub them first.
|
|
# Ported from pi-commandcode-provider src/overflow.ts.
|
|
|
|
_REDACT = [
|
|
(re.compile(r'\bBearer\s+[A-Za-z0-9._~+/=-]+', re.I), 'Bearer [redacted]'),
|
|
(re.compile(r'\b(?:user|cc)_[A-Za-z0-9_-]{8,}\b', re.I), '[redacted]'),
|
|
(re.compile(r'([?&](?:api[-_ ]?key|apikey|access_token|refresh_token|token|secret|'
|
|
r'password)=)[^&#\s]+', re.I), r'\1[redacted]'),
|
|
(re.compile(r'\b(?:sk|rk|ghp|github_pat|xox[baprs])[-_A-Za-z0-9]{16,}\b'
|
|
r'|\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b'), '[redacted]'),
|
|
]
|
|
_REDACT_KV = re.compile(
|
|
r'\b(?:api[-_ ]?key|apikey|access[-_ ]?token|refresh[-_ ]?token|token|secret|password|'
|
|
r'authorization)\s*[=:]\s*[^\s,;)"\']+', re.I)
|
|
|
|
|
|
def redact(text):
|
|
"""Strip credentials from arbitrary text before logging or forwarding it."""
|
|
if not text:
|
|
return text
|
|
for pattern, repl in _REDACT:
|
|
text = pattern.sub(repl, text)
|
|
|
|
def _kv(m):
|
|
s = m.group(0)
|
|
i = min((s.find(c) for c in '=:' if c in s), default=-1)
|
|
return s if i < 0 else s[:i + 1] + '[redacted]'
|
|
|
|
return _REDACT_KV.sub(_kv, text)
|
|
|
|
|
|
# -- logging ------------------------------------------------------------------
|
|
|
|
C = {
|
|
'reset': '\x1b[0m', 'cyan': '\x1b[36m', 'green': '\x1b[32m',
|
|
'yellow': '\x1b[33m', 'dim': '\x1b[2m', 'bold': '\x1b[1m',
|
|
}
|
|
LEVEL_COLORS = {'req': C['cyan'], 'upstream': C['green'], 'done': C['bold'], 'error': C['yellow']}
|
|
|
|
_log_lock = threading.Lock()
|
|
_log_file = open(os.path.join(BASE_DIR, CONFIG['log_file']), 'w', encoding='utf-8')
|
|
|
|
|
|
def _now_iso():
|
|
now = datetime.now(timezone.utc)
|
|
return now.strftime('%Y-%m-%dT%H:%M:%S.') + f'{now.microsecond // 1000:03d}Z'
|
|
|
|
|
|
def write_log(level, msg):
|
|
msg = redact(msg)
|
|
ts = f'[{_now_iso()}]'
|
|
c = LEVEL_COLORS.get(level, C['reset'])
|
|
tag = f'{c}{level}{C["reset"]}' if level else ''
|
|
full = f'{C["dim"]}{ts}{C["reset"]} ' + (f'[{tag}] ' if tag else '') + msg
|
|
plain = f'{ts}' + (f' [{level}] ' if tag else ' ') + msg
|
|
with _log_lock:
|
|
sys.stdout.write(full + '\n')
|
|
sys.stdout.flush()
|
|
_log_file.write(plain + '\n')
|
|
_log_file.flush()
|
|
|
|
|
|
def log_req(msg):
|
|
write_log('req', msg)
|
|
|
|
|
|
def log_up(msg):
|
|
write_log('upstream', msg)
|
|
|
|
|
|
def log_done(msg):
|
|
write_log('done', msg)
|
|
|
|
|
|
def log_err(msg):
|
|
write_log('error', msg)
|
|
|
|
|
|
def log(msg):
|
|
write_log('', msg)
|
|
|
|
|
|
_ANSI = re.compile(r'\x1b\[[0-9;]*m')
|
|
|
|
|
|
def banner():
|
|
def strip(s):
|
|
return _ANSI.sub('', s)
|
|
|
|
def pad(s, w):
|
|
return s + ' ' * max(0, w - len(strip(s)))
|
|
|
|
def L(s):
|
|
return f'{C["bold"]}{s}{C["reset"]}'
|
|
|
|
title = f'{C["cyan"]}{C["bold"]}Proxy CommandCode{C["reset"]}'
|
|
sub = f'{C["dim"]}OpenAI → CommandCode /alpha/generate{C["reset"]}'
|
|
rows = [
|
|
f'{L("Listening")} http://localhost:{PORT}',
|
|
f'{L("Endpoint")} /v1/chat/completions',
|
|
f'{L("Upstream")} {HOST}{PATH}',
|
|
f'{L("CC Version")} {CC_VERSION}',
|
|
f'{L("Debug")} ' + (f'{C["green"]}ON{C["reset"]}' if DEBUG else f'{C["yellow"]}OFF{C["reset"]}'),
|
|
]
|
|
|
|
all_rows = [title, sub] + rows
|
|
w = max(len(strip(s)) for s in all_rows)
|
|
|
|
def box(s):
|
|
return f'{C["cyan"]}│{C["reset"]} {pad(s, w)} {C["cyan"]}│{C["reset"]}'
|
|
|
|
print(f'{C["cyan"]}┌{"─" * (w + 4)}┐{C["reset"]}')
|
|
print(box(title))
|
|
print(box(sub))
|
|
print(f'{C["cyan"]}├{"─" * (w + 4)}┤{C["reset"]}')
|
|
for r in rows:
|
|
print(box(r))
|
|
print(f'{C["cyan"]}└{"─" * (w + 4)}┘{C["reset"]}')
|
|
|
|
|
|
# -- transform ----------------------------------------------------------------
|
|
|
|
CORS = {
|
|
'Access-Control-Allow-Origin': '*',
|
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
}
|
|
|
|
STATIC_CONFIG = {
|
|
'workingDir': '', 'date': '', 'environment': '',
|
|
'structure': [], 'isGitRepo': False, 'currentBranch': '',
|
|
'mainBranch': 'main', 'gitStatus': '', 'recentCommits': [],
|
|
}
|
|
|
|
ENVIRONMENT_INFO = f'{sys.platform}-{platform.machine()}, Python {platform.python_version()}'
|
|
|
|
# Sampling knobs the OpenAI request may carry. Forwarded verbatim into params when present;
|
|
# CommandCode ignores what it does not understand rather than rejecting the request.
|
|
PASSTHROUGH_PARAMS = ('temperature', 'top_p', 'top_k', 'stop', 'seed',
|
|
'presence_penalty', 'frequency_penalty', 'reasoning_effort')
|
|
|
|
|
|
def _js_str(v):
|
|
"""String(v) the way JavaScript does it for the values we actually see."""
|
|
if v is None:
|
|
return 'null'
|
|
if v is True:
|
|
return 'true'
|
|
if v is False:
|
|
return 'false'
|
|
if isinstance(v, str):
|
|
return v
|
|
return json.dumps(v, ensure_ascii=False)
|
|
|
|
|
|
def _dumps(obj):
|
|
return json.dumps(obj, separators=(',', ':'), ensure_ascii=False)
|
|
|
|
|
|
def _today():
|
|
return datetime.now(timezone.utc).strftime('%Y-%m-%d')
|
|
|
|
|
|
def _tool_input(arguments):
|
|
"""OpenAI streams tool arguments as a JSON string; CommandCode wants the object.
|
|
|
|
Fragments are not always complete JSON, so fall back to the raw string.
|
|
(pi-commandcode-provider `recordOrEmpty`, src/converters.ts:23)
|
|
"""
|
|
if isinstance(arguments, dict):
|
|
return arguments
|
|
if isinstance(arguments, str):
|
|
try:
|
|
parsed = json.loads(arguments)
|
|
if isinstance(parsed, dict):
|
|
return parsed
|
|
except ValueError:
|
|
pass
|
|
return arguments
|
|
|
|
|
|
def _image_part(url):
|
|
"""CommandCode expects {image: <data-uri>, mimeType}, not OpenAI's {url}.
|
|
|
|
(pi-commandcode-provider `imageToCommandCode`, src/converters.ts:76)
|
|
"""
|
|
if not isinstance(url, str):
|
|
return None
|
|
m = re.match(r'data:([^;,]+);base64,', url)
|
|
if m:
|
|
return {'type': 'image', 'image': url, 'mimeType': m.group(1)}
|
|
# Remote URLs are passed through as-is; CommandCode may or may not fetch them,
|
|
# but rewriting them into a data URI would mean downloading on its behalf.
|
|
return {'type': 'image', 'image': url}
|
|
|
|
|
|
def _apply_tool_choice(tools, value):
|
|
"""OpenAI `tool_choice` -> (tools to send, params.tool_choice to send).
|
|
|
|
Upstream accepts exactly one value, `{"type": "auto"}` — anything else answers
|
|
`expected "auto" at "params.tool_choice.type"`, and a bare string answers
|
|
`expected object, received string`. So only `auto` is expressible upstream; the rest
|
|
are emulated by shaping the tool list, which is what actually constrains the model:
|
|
|
|
none -> send no tools at all; a tool call becomes impossible (exact)
|
|
{function: "X"} -> send only X; strongly biases toward it (not a guarantee)
|
|
required -> not expressible, and not emulable; left to the model
|
|
"""
|
|
if value is None:
|
|
return tools, None
|
|
if isinstance(value, dict) and value.get('type') == 'function':
|
|
name = (value.get('function') or {}).get('name')
|
|
if name:
|
|
picked = [t for t in tools if t.get('name') == name]
|
|
return (picked or tools), None
|
|
value = 'auto'
|
|
if isinstance(value, dict):
|
|
value = value.get('type') # already upstream-shaped, or OpenAI's object form
|
|
if value == 'auto':
|
|
return tools, {'type': 'auto'}
|
|
if value == 'none':
|
|
return [], None
|
|
if value == 'required':
|
|
return tools, None # upstream cannot force a call; the model decides
|
|
log_err(f'[tool_choice] unrecognised, ignoring: {_dumps(value)[:120]}')
|
|
return tools, None
|
|
|
|
|
|
def _paired_tool_call_ids(src):
|
|
"""Tool call ids that have a matching result, and vice versa.
|
|
|
|
An assistant tool_call with no tool result (or a result with no call) makes the
|
|
upstream reject the whole request; editors truncate history and produce exactly that.
|
|
(pi-commandcode-provider `completeToolCallIds`, src/converters.ts:162)
|
|
"""
|
|
calls, results = set(), set()
|
|
for m in src:
|
|
if m.get('role') == 'assistant':
|
|
for tc in m.get('tool_calls') or []:
|
|
if tc.get('id'):
|
|
calls.add(tc['id'])
|
|
elif m.get('role') == 'tool' and m.get('tool_call_id'):
|
|
results.add(m['tool_call_id'])
|
|
return calls & results
|
|
|
|
|
|
def transform(oai_body):
|
|
"""OpenAI request body -> CommandCode /alpha/generate envelope (bytes)."""
|
|
model = oai_body.get('model') or DEFAULT_MODEL
|
|
system_parts = []
|
|
messages = []
|
|
src = oai_body.get('messages') or []
|
|
paired = _paired_tool_call_ids(src)
|
|
|
|
# tool_call_id -> tool name, so tool results can be attributed
|
|
tool_name_map = {}
|
|
for m in src:
|
|
if m.get('role') == 'assistant' and m.get('tool_calls'):
|
|
for tc in m['tool_calls']:
|
|
fn = tc.get('function') or {}
|
|
if tc.get('id') and fn.get('name'):
|
|
tool_name_map[tc['id']] = fn['name']
|
|
|
|
for m in src:
|
|
role = m.get('role')
|
|
content = m.get('content')
|
|
|
|
if role == 'system':
|
|
if isinstance(content, str):
|
|
system_parts.append(content)
|
|
elif isinstance(content, list):
|
|
system_parts.append('\n'.join(
|
|
p.get('text', '') for p in content if isinstance(p, dict) and p.get('type') == 'text'))
|
|
else:
|
|
system_parts.append(_js_str(content))
|
|
continue
|
|
|
|
if role == 'tool':
|
|
if m.get('tool_call_id') not in paired:
|
|
continue # orphan result: upstream rejects the whole request
|
|
if isinstance(content, str):
|
|
out = {'type': 'text', 'value': content}
|
|
elif content:
|
|
out = content
|
|
else:
|
|
out = {'type': 'text', 'value': _js_str(content)}
|
|
messages.append({'role': 'tool', 'content': [{
|
|
'type': 'tool-result',
|
|
'toolCallId': m.get('tool_call_id'),
|
|
'toolName': tool_name_map.get(m.get('tool_call_id')) or 'unknown',
|
|
'output': out,
|
|
}]})
|
|
continue
|
|
|
|
if role == 'assistant':
|
|
parts = []
|
|
if content:
|
|
if isinstance(content, str):
|
|
parts.append({'type': 'text', 'text': content})
|
|
elif isinstance(content, list):
|
|
for p in content:
|
|
if isinstance(p, dict) and p.get('type') == 'text':
|
|
parts.append({'type': 'text', 'text': p.get('text')})
|
|
for tc in m.get('tool_calls') or []:
|
|
if tc.get('type') == 'function' and tc.get('function'):
|
|
if tc.get('id') not in paired:
|
|
continue # unanswered call: same rejection risk as above
|
|
parts.append({
|
|
'type': 'tool-call',
|
|
'toolCallId': tc.get('id'),
|
|
'toolName': tc['function'].get('name'),
|
|
'input': _tool_input(tc['function'].get('arguments')),
|
|
})
|
|
if parts:
|
|
messages.append({'role': 'assistant', 'content': parts})
|
|
continue
|
|
|
|
if isinstance(content, str):
|
|
messages.append({'role': role, 'content': [{'type': 'text', 'text': content}]})
|
|
elif isinstance(content, list):
|
|
parts = []
|
|
for p in content:
|
|
if not isinstance(p, dict):
|
|
continue
|
|
if p.get('type') == 'text':
|
|
parts.append({'type': 'text', 'text': p.get('text')})
|
|
elif p.get('type') == 'image_url':
|
|
img = _image_part((p.get('image_url') or {}).get('url'))
|
|
if img:
|
|
parts.append(img)
|
|
messages.append({'role': role, 'content': parts})
|
|
else:
|
|
messages.append({'role': role, 'content': [{'type': 'text', 'text': _js_str(content)}]})
|
|
|
|
tools = []
|
|
for t in oai_body.get('tools') or []:
|
|
fn = t.get('function') or {}
|
|
tools.append({
|
|
'type': 'function',
|
|
'name': fn.get('name') or t.get('name'),
|
|
'description': fn.get('description') or t.get('description') or '',
|
|
'input_schema': fn.get('parameters') or t.get('input_schema') or {'type': 'object', 'properties': {}},
|
|
})
|
|
|
|
tools, tool_choice = _apply_tool_choice(tools, oai_body.get('tool_choice'))
|
|
|
|
system_text = '\n\n'.join(p for p in system_parts if p)
|
|
|
|
params = {'model': model}
|
|
if system_text:
|
|
params['system'] = system_text
|
|
params['messages'] = messages
|
|
if tools:
|
|
params['tools'] = tools
|
|
if tool_choice:
|
|
params['tool_choice'] = tool_choice
|
|
params['max_tokens'] = oai_body.get('max_tokens') or DEFAULT_MAX_TOKENS
|
|
# Always stream upstream, whatever the client asked for. `stream: false` makes the
|
|
# endpoint answer "Proxy use detected. This endpoint only serves CLI." — the real CLI
|
|
# never sends it (pi-commandcode-provider hardcodes stream: true, src/core.ts:495).
|
|
# A non-streaming client is served by buffering the NDJSON here instead.
|
|
params['stream'] = True
|
|
# All eight confirmed accepted by /alpha/generate (probed individually); the endpoint
|
|
# normalises most of them away, but none of them 400.
|
|
for key in PASSTHROUGH_PARAMS:
|
|
if oai_body.get(key) is not None:
|
|
params[key] = oai_body[key]
|
|
|
|
cfg = dict(STATIC_CONFIG)
|
|
cfg['date'] = _today()
|
|
cfg['environment'] = ENVIRONMENT_INFO
|
|
|
|
# No threadId: measured A/B (stable vs random) showed identical cache growth (+640
|
|
# cached tokens either way), so upstream caching is content-based and the field only
|
|
# adds a UUID-validation failure mode.
|
|
return _dumps({
|
|
'config': cfg,
|
|
'memory': None, 'taste': None, 'skills': None, 'permissionMode': 'standard',
|
|
'params': params,
|
|
}).encode('utf-8')
|
|
|
|
|
|
# -- upstream finish event ----------------------------------------------------
|
|
|
|
ZERO_USAGE = {'prompt_tokens': 0, 'completion_tokens': 0, 'total_tokens': 0}
|
|
|
|
|
|
def usage_from_finish(evt):
|
|
"""CommandCode `finish` -> OpenAI `usage`.
|
|
|
|
Live shape (confirmed from dump/):
|
|
{"type":"finish","finishReason":"stop","rawFinishReason":"stop",
|
|
"totalUsage":{"inputTokens":8233,"outputTokens":35,"totalTokens":8268,
|
|
"inputTokenDetails":{"noCacheTokens":553,"cacheReadTokens":7680},
|
|
"outputTokenDetails":{"textTokens":35,"reasoningTokens":0},
|
|
"cachedInputTokens":7680}}
|
|
"""
|
|
tu = evt.get('totalUsage')
|
|
if not isinstance(tu, dict):
|
|
return None
|
|
prompt = tu.get('inputTokens') or 0
|
|
completion = tu.get('outputTokens') or 0
|
|
usage = {
|
|
'prompt_tokens': prompt,
|
|
'completion_tokens': completion,
|
|
'total_tokens': tu.get('totalTokens') or (prompt + completion),
|
|
}
|
|
details = tu.get('inputTokenDetails') or {}
|
|
cached = tu.get('cachedInputTokens')
|
|
if cached is None:
|
|
cached = details.get('cacheReadTokens')
|
|
if cached is not None:
|
|
usage['prompt_tokens_details'] = {'cached_tokens': cached}
|
|
out_details = tu.get('outputTokenDetails') or {}
|
|
if out_details.get('reasoningTokens') is not None:
|
|
usage['completion_tokens_details'] = {'reasoning_tokens': out_details['reasoningTokens']}
|
|
return usage
|
|
|
|
|
|
def finish_reason_from(evt, had_tool_calls):
|
|
"""`rawFinishReason` is already OpenAI-spelled; `finishReason` uses dashes.
|
|
|
|
(pi-commandcode-provider `mapFinishReason`, src/converters.ts:258)
|
|
"""
|
|
raw = evt.get('rawFinishReason')
|
|
if raw in ('stop', 'length', 'tool_calls', 'content_filter', 'function_call'):
|
|
return raw
|
|
reason = evt.get('finishReason')
|
|
if reason in ('tool-calls', 'tool_calls'):
|
|
return 'tool_calls'
|
|
if reason in ('length', 'max_tokens', 'max-tokens', 'max_output_tokens'):
|
|
return 'length'
|
|
if reason == 'stop':
|
|
return 'tool_calls' if had_tool_calls else 'stop'
|
|
return None
|
|
|
|
|
|
def openai_error(message, code=None, etype='upstream_error'):
|
|
"""OpenAI-shaped error envelope. Clients read error.message; give them that field."""
|
|
return {'error': {'message': redact(str(message)), 'type': etype,
|
|
'param': None, 'code': code}}
|
|
|
|
|
|
# -- retry --------------------------------------------------------------------
|
|
# Ported from pi-commandcode-provider src/core.ts:53-85. Only applied before any byte
|
|
# reaches the client — retrying mid-stream would duplicate half a response.
|
|
|
|
BASE_RETRY_DELAY = 0.5
|
|
|
|
|
|
def is_retryable(status):
|
|
return status == 429 or 500 <= status < 600
|
|
|
|
|
|
def retry_delay(attempt, retry_after):
|
|
"""Honour Retry-After when present, else exponential backoff with jitter.
|
|
|
|
Returns -1 when the server asks for longer than we are willing to wait.
|
|
"""
|
|
if retry_after:
|
|
try:
|
|
seconds = float(retry_after)
|
|
except ValueError:
|
|
seconds = None
|
|
if seconds is not None and seconds >= 0:
|
|
return -1 if seconds > MAX_RETRY_DELAY else seconds
|
|
exponential = BASE_RETRY_DELAY * (2 ** attempt)
|
|
return min(exponential + exponential * 0.2 * random.random(), MAX_RETRY_DELAY)
|
|
|
|
|
|
def _close(conn):
|
|
try:
|
|
if conn:
|
|
conn.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# -- handler ------------------------------------------------------------------
|
|
|
|
CLIENT_GONE = (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, OSError)
|
|
|
|
|
|
class ProxyHandler(BaseHTTPRequestHandler):
|
|
protocol_version = 'HTTP/1.1'
|
|
disable_nagle_algorithm = True
|
|
server_version = 'proxy-commandcode'
|
|
sys_version = ''
|
|
timeout = TIMEOUT
|
|
|
|
def log_message(self, fmt, *args):
|
|
pass # our own logging only
|
|
|
|
def handle_one_request(self):
|
|
# A client that walks away mid-stream (editor cancels a completion) is routine;
|
|
# the base class' trailing wfile.flush() would raise it up to socketserver.
|
|
try:
|
|
super().handle_one_request()
|
|
except CLIENT_GONE:
|
|
self.close_connection = True
|
|
|
|
# -- response helpers --
|
|
|
|
def _cors_headers(self):
|
|
for k, v in CORS.items():
|
|
self.send_header(k, v)
|
|
|
|
def _send_bytes(self, status, body, content_type='application/json', extra=None):
|
|
if self._headers_sent:
|
|
return
|
|
self._headers_sent = True
|
|
try:
|
|
self.send_response(status)
|
|
self._cors_headers()
|
|
for k, v in (extra or {}).items():
|
|
self.send_header(k, v)
|
|
if body and content_type:
|
|
self.send_header('Content-Type', content_type)
|
|
self.send_header('Content-Length', str(len(body or b'')))
|
|
self.end_headers()
|
|
if body:
|
|
self.wfile.write(body)
|
|
except CLIENT_GONE:
|
|
self.close_connection = True
|
|
finally:
|
|
self._log_response(status, content_type, len(body or b''))
|
|
|
|
def _send_json(self, status, obj, extra=None):
|
|
self._send_bytes(status, _dumps(obj).encode('utf-8'), 'application/json', extra)
|
|
|
|
def _log_response(self, status, content_type, nbytes):
|
|
"""One compact line per non-streaming response, only when debug is on."""
|
|
if not DEBUG:
|
|
return
|
|
ip = self.client_address[0] if self.client_address else '-'
|
|
ms = int((time.time() - self._t0) * 1000)
|
|
log_req(f'[detail] {self.command or "-"} {self.path or "-"} | {ip} | {status} | '
|
|
f'{content_type or "-"} | {nbytes} B | {ms}ms')
|
|
|
|
def _debug_detail(self, oai, upstream):
|
|
"""Headers, parsed request body, and both params shapes, pretty-printed."""
|
|
if not DEBUG:
|
|
return
|
|
lines = ['[detail] request', ' -- headers ' + '-' * 40]
|
|
for k, v in (self.headers.items() if self.headers else []):
|
|
lines.append(f' {k}: {v}')
|
|
lines.append(' -- request body ' + '-' * 36)
|
|
lines.append(' ' + json.dumps(oai, indent=2, ensure_ascii=False).replace('\n', '\n '))
|
|
lines.append(' -- params (original OpenAI) ' + '-' * 25)
|
|
orig = {k: v for k, v in oai.items()
|
|
if k in ('model', 'max_tokens', 'stream', 'stream_options') or k in PASSTHROUGH_PARAMS}
|
|
lines.append(' ' + json.dumps(orig, indent=2, ensure_ascii=False).replace('\n', '\n '))
|
|
lines.append(' -- params (transformed upstream) ' + '-' * 21)
|
|
try:
|
|
transformed = json.loads(upstream.decode('utf-8')).get('params', {})
|
|
except (ValueError, UnicodeDecodeError, AttributeError):
|
|
transformed = {}
|
|
lines.append(' ' + json.dumps(transformed, indent=2, ensure_ascii=False).replace('\n', '\n '))
|
|
log_req('\n'.join(lines))
|
|
|
|
def _begin_stream(self, content_type='text/event-stream'):
|
|
"""HTTP/1.1 chunked: BaseHTTPRequestHandler will not frame writes for us."""
|
|
if self._headers_sent:
|
|
return
|
|
self._headers_sent = True
|
|
self._streaming = True
|
|
try:
|
|
self.send_response(200)
|
|
self._cors_headers()
|
|
self.send_header('Content-Type', content_type)
|
|
self.send_header('Cache-Control', 'no-cache')
|
|
self.send_header('Transfer-Encoding', 'chunked')
|
|
self.end_headers()
|
|
except CLIENT_GONE:
|
|
self._client_gone = True
|
|
self.close_connection = True
|
|
|
|
def _write_chunk(self, data):
|
|
if self._client_gone or not data:
|
|
return
|
|
try:
|
|
self.wfile.write(f'{len(data):X}\r\n'.encode('ascii') + data + b'\r\n')
|
|
self.wfile.flush()
|
|
except CLIENT_GONE:
|
|
self._client_gone = True
|
|
self.close_connection = True
|
|
|
|
def _write_sse(self, obj):
|
|
self._write_chunk(f'data: {_dumps(obj)}\n\n'.encode('utf-8'))
|
|
|
|
def _end_stream(self):
|
|
if not self._streaming or self._stream_ended:
|
|
return
|
|
self._stream_ended = True
|
|
if self._client_gone:
|
|
return
|
|
try:
|
|
self.wfile.write(b'0\r\n\r\n')
|
|
self.wfile.flush()
|
|
except CLIENT_GONE:
|
|
self._client_gone = True
|
|
self.close_connection = True
|
|
|
|
def _reset(self):
|
|
self._headers_sent = False
|
|
self._streaming = False
|
|
self._stream_ended = False
|
|
self._client_gone = False
|
|
self._t0 = time.time()
|
|
|
|
def _read_body(self):
|
|
"""Always consume the body, even on an early error — an undrained body would be
|
|
parsed as the next request on this keep-alive connection. Returns None if the body
|
|
is over the limit."""
|
|
if (self.headers.get('Transfer-Encoding') or '').lower() == 'chunked':
|
|
return self._read_chunked_body()
|
|
try:
|
|
length = int(self.headers.get('Content-Length') or 0)
|
|
except ValueError:
|
|
length = 0
|
|
if length > MAX_BODY:
|
|
self.close_connection = True
|
|
return None
|
|
try:
|
|
return self.rfile.read(length) if length else b''
|
|
except CLIENT_GONE:
|
|
self.close_connection = True
|
|
return None
|
|
|
|
def _read_chunked_body(self):
|
|
"""Not every client sends Content-Length; Node's http server decodes this for free."""
|
|
parts, total = [], 0
|
|
try:
|
|
while True:
|
|
size = int(self.rfile.readline(64).split(b';')[0].strip() or b'0', 16)
|
|
if size == 0:
|
|
while self.rfile.readline(65536).strip():
|
|
pass # trailers
|
|
break
|
|
total += size
|
|
if total > MAX_BODY:
|
|
self.close_connection = True
|
|
return None
|
|
parts.append(self.rfile.read(size))
|
|
self.rfile.read(2) # trailing CRLF
|
|
except (ValueError,) + CLIENT_GONE:
|
|
self.close_connection = True
|
|
return b'' # malformed framing -> let it fail as a bad request
|
|
return b''.join(parts)
|
|
|
|
# -- routes --
|
|
|
|
def do_OPTIONS(self):
|
|
self._reset()
|
|
self._read_body()
|
|
self._send_bytes(204, b'', None, {
|
|
'Access-Control-Allow-Methods': 'POST,GET,OPTIONS',
|
|
'Access-Control-Max-Age': '86400',
|
|
})
|
|
|
|
def do_GET(self):
|
|
self._reset()
|
|
self._read_body()
|
|
if self.path == '/health':
|
|
self._send_json(200, {'status': 'ok'})
|
|
elif self.path.rstrip('/').endswith('/v1/models') or self.path.rstrip('/') == '/models':
|
|
self._models()
|
|
else:
|
|
self._send_json(404, openai_error('POST /v1/chat/completions',
|
|
code='not_found', etype='invalid_request_error'))
|
|
|
|
def _models(self):
|
|
"""Many clients call /v1/models before they will talk to an endpoint at all.
|
|
|
|
CommandCode's catalog already answers in OpenAI's {object:"list", data:[...]} shape,
|
|
but it lives on the Pro-only /provider surface — so fall back to config.json's
|
|
fallback_models when it refuses.
|
|
"""
|
|
auth = self.headers.get('Authorization') or ''
|
|
try:
|
|
conn = http.client.HTTPSConnection(HOST, timeout=30)
|
|
conn.request('GET', MODELS_PATH, headers={
|
|
'Authorization': auth, 'x-command-code-version': CC_VERSION})
|
|
r = conn.getresponse()
|
|
body = r.read()
|
|
status = r.status
|
|
conn.close()
|
|
except Exception as e:
|
|
log_err(f'[models] {e}')
|
|
status, body = 0, b''
|
|
|
|
log_up(f'{status or "ERR"} | models')
|
|
if status == 200:
|
|
self._send_bytes(200, body)
|
|
return
|
|
|
|
now = int(time.time())
|
|
self._send_json(200, {'object': 'list', 'data': [
|
|
{'id': m, 'object': 'model', 'created': now, 'owned_by': 'commandcode'}
|
|
for m in FALLBACK_MODELS or [DEFAULT_MODEL]]})
|
|
|
|
def do_POST(self):
|
|
self._reset()
|
|
body = self._read_body()
|
|
if body is None:
|
|
self._send_bytes(413, b'{}')
|
|
return
|
|
|
|
if not self.path.startswith('/v1/chat/completions'):
|
|
self._send_json(404, openai_error('POST /v1/chat/completions',
|
|
code='not_found', etype='invalid_request_error'))
|
|
return
|
|
|
|
try:
|
|
oai = json.loads(body.decode('utf-8'))
|
|
except (ValueError, UnicodeDecodeError):
|
|
self._send_json(400, openai_error('Invalid JSON', code='invalid_json',
|
|
etype='invalid_request_error'))
|
|
return
|
|
|
|
model = oai.get('model') or '-'
|
|
is_stream = oai.get('stream') is True
|
|
ip = self.client_address[0] if self.client_address else '-'
|
|
t0 = time.time()
|
|
log_req(f'{model} | {ip} | {"stream" if is_stream else "sync"} | {len(body)} bytes')
|
|
|
|
debug_transform_logs = ['[detail] request', ' -- headers (org) ' + '-' * 40]
|
|
for k, v in (self.headers.items() if self.headers else []):
|
|
debug_transform_logs.append(f' {k}: {v}')
|
|
debug_transform_logs.append(' -- body (org) ' + '-' * 36)
|
|
debug_transform_logs.append(' ' + json.dumps(oai, indent=2, ensure_ascii=False).replace('\n', '\n '))
|
|
|
|
try:
|
|
upstream = transform(oai)
|
|
except Exception:
|
|
self._send_json(500, openai_error('Transform error', code='transform_error',
|
|
etype='proxy_error'))
|
|
return
|
|
|
|
include_usage = bool((oai.get('stream_options') or {}).get('include_usage'))
|
|
auth = self.headers.get('Authorization') or ''
|
|
headers = {
|
|
'Content-Type': 'application/json',
|
|
'Content-Length': str(len(upstream)),
|
|
'Authorization': auth,
|
|
'x-command-code-version': CC_VERSION,
|
|
'x-cli-environment': 'production',
|
|
'x-project-slug': 'project',
|
|
'x-taste-learning': 'true',
|
|
'x-co-flag': 'false',
|
|
}
|
|
|
|
debug_transform_logs.append(' -- headers (xform) ' + '-' * 40)
|
|
for k, v in (headers.items() if headers else []):
|
|
debug_transform_logs.append(f' {k}: {v}')
|
|
debug_transform_logs.append(' -- body (xform) ' + '-' * 36)
|
|
transformed = json.loads(upstream.decode('utf-8'))
|
|
debug_transform_logs.append(' ' + json.dumps(transformed, indent=2, ensure_ascii=False).replace('\n', '\n '))
|
|
log_req('\n'.join(debug_transform_logs))
|
|
|
|
for attempt in range(MAX_RETRIES + 1):
|
|
conn, resp = None, None
|
|
try:
|
|
conn = http.client.HTTPSConnection(HOST, timeout=TIMEOUT)
|
|
conn.request('POST', PATH, body=upstream, headers=headers)
|
|
resp = conn.getresponse()
|
|
except socket.timeout:
|
|
_close(conn)
|
|
if attempt < MAX_RETRIES:
|
|
log_err(f'[upstream] timeout, retry {attempt + 1}/{MAX_RETRIES}')
|
|
continue
|
|
log_err('[upstream] timeout')
|
|
self._send_json(504, openai_error('Upstream timed out', code='timeout'))
|
|
return
|
|
except Exception as e:
|
|
_close(conn)
|
|
if attempt < MAX_RETRIES:
|
|
log_err(f'[upstream] {e}, retry {attempt + 1}/{MAX_RETRIES}')
|
|
time.sleep(retry_delay(attempt, None))
|
|
continue
|
|
log_err(f'[upstream] {e}')
|
|
self._send_json(502, openai_error(e, code='upstream_unreachable'))
|
|
return
|
|
|
|
ok = 200 <= resp.status < 300
|
|
log_up(f'{resp.status} {"OK" if ok else "ERR"} | {model} | '
|
|
f'{int((time.time() - t0) * 1000)}ms')
|
|
|
|
# 429/5xx are worth another go; nothing has reached the client yet.
|
|
if is_retryable(resp.status) and attempt < MAX_RETRIES:
|
|
wait = retry_delay(attempt, resp.getheader('Retry-After'))
|
|
if wait >= 0:
|
|
try:
|
|
resp.read()
|
|
except Exception:
|
|
pass
|
|
_close(conn)
|
|
log_err(f'[upstream] {resp.status}, retry {attempt + 1}/{MAX_RETRIES} '
|
|
f'in {wait:.1f}s')
|
|
time.sleep(wait)
|
|
continue
|
|
|
|
try:
|
|
self._handle_upstream(resp, model, is_stream, t0, include_usage)
|
|
finally:
|
|
_close(conn)
|
|
return
|
|
|
|
# -- upstream response --
|
|
|
|
def _handle_upstream(self, resp, model, is_stream, t0, include_usage=False):
|
|
if resp.status >= 400:
|
|
try:
|
|
body = resp.read().decode('utf-8', 'replace')
|
|
except Exception:
|
|
body = ''
|
|
# CommandCode answers {"success":false,"error":{...}}; clients expect OpenAI's
|
|
# {"error":{"message":...}}. Reshape, and redact any echoed credential.
|
|
message, code = body, None
|
|
try:
|
|
parsed = json.loads(body)
|
|
err = parsed.get('error') if isinstance(parsed, dict) else None
|
|
if isinstance(err, dict):
|
|
message = err.get('message') or body
|
|
code = err.get('code')
|
|
elif isinstance(err, str):
|
|
message = err
|
|
except ValueError:
|
|
pass
|
|
self._send_json(resp.status, openai_error(message[:2000], code=code))
|
|
return
|
|
|
|
gen_id = 'chatcmpl-' + str(int(time.time() * 1000))
|
|
dump = None
|
|
if DEBUG:
|
|
os.makedirs(os.path.join(BASE_DIR, 'dump'), exist_ok=True)
|
|
dump = open(os.path.join(BASE_DIR, 'dump', f'dump-{gen_id}.txt'), 'wb')
|
|
log(f'[debug] dumping to dump/dump-{gen_id}.txt')
|
|
|
|
try:
|
|
if is_stream:
|
|
self._stream_response(resp, model, gen_id, t0, dump, include_usage)
|
|
else:
|
|
self._buffer_response(resp, model, gen_id, t0, dump)
|
|
except socket.timeout:
|
|
log_err('[upstream] timeout')
|
|
if self._streaming:
|
|
self._end_stream()
|
|
else:
|
|
self._send_json(504, openai_error('Upstream timed out', code='timeout'))
|
|
except CLIENT_GONE as e:
|
|
log_err(f'[upstream] {e}')
|
|
if self._streaming:
|
|
self._end_stream()
|
|
else:
|
|
self._send_json(502, openai_error(e, code='upstream_error'))
|
|
finally:
|
|
if dump:
|
|
dump.close()
|
|
|
|
@staticmethod
|
|
def _events(resp, dump):
|
|
"""CommandCode streams NDJSON; readline yields one event as soon as it lands.
|
|
|
|
Tolerates SSE-style `data:` prefixes and `[DONE]` sentinels, which the upstream
|
|
emits on some routes (pi-commandcode-provider `parseStreamEventLine`).
|
|
"""
|
|
for raw in resp:
|
|
if dump:
|
|
dump.write(raw)
|
|
line = raw.strip()
|
|
if not line or line.startswith(b':') or line.startswith(b'event:'):
|
|
continue
|
|
if line.startswith(b'data:'):
|
|
line = line[5:].strip()
|
|
if not line or line == b'[DONE]':
|
|
continue
|
|
try:
|
|
yield json.loads(line.decode('utf-8'))
|
|
except (ValueError, UnicodeDecodeError):
|
|
continue
|
|
|
|
def _buffer_response(self, resp, model, gen_id, t0, dump):
|
|
full_text, full_reasoning, error_msg = '', '', ''
|
|
tool_calls, tool_part = [], None
|
|
usage, finish_reason = None, None
|
|
|
|
for evt in self._events(resp, dump):
|
|
kind = evt.get('type')
|
|
if kind == 'error':
|
|
err = evt.get('error')
|
|
error_msg = (err or {}).get('message') if isinstance(err, dict) else None
|
|
error_msg = error_msg or _dumps(err)
|
|
elif kind == 'text-delta':
|
|
full_text += evt.get('text') or ''
|
|
elif kind == 'reasoning-delta':
|
|
full_reasoning += evt.get('text') or ''
|
|
elif kind == 'tool-input-start':
|
|
tool_part = {'id': evt.get('id'), 'type': 'function',
|
|
'function': {'name': evt.get('toolName'), 'arguments': ''}}
|
|
tool_calls.append(tool_part)
|
|
elif kind == 'tool-input-delta':
|
|
if evt.get('delta') and tool_part:
|
|
tool_part['function']['arguments'] += evt['delta']
|
|
elif kind == 'tool-call':
|
|
# authoritative parsed arguments; the deltas can be fragments
|
|
for tc in tool_calls:
|
|
if tc['id'] == evt.get('toolCallId') and isinstance(evt.get('input'), dict):
|
|
tc['function']['arguments'] = _dumps(evt['input'])
|
|
tool_part = None
|
|
elif kind == 'tool-input-end':
|
|
tool_part = None
|
|
elif kind == 'finish':
|
|
usage = usage_from_finish(evt) or usage
|
|
finish_reason = finish_reason_from(evt, bool(tool_calls)) or finish_reason
|
|
break # nothing meaningful follows the finish event
|
|
|
|
if error_msg:
|
|
log_err(f'[error] {model} | {error_msg}')
|
|
self._send_json(502, openai_error(error_msg, code='context_length_exceeded'))
|
|
return
|
|
|
|
# Reasoning is NOT content. Substituting one for the other shows the model's private
|
|
# chain-of-thought as the answer; clients read it from reasoning_content (DeepSeek)
|
|
# or reasoning (vLLM/OpenRouter), so emit both and leave content to the real reply.
|
|
msg = {'role': 'assistant', 'content': full_text or None, 'refusal': None,
|
|
'annotations': [], 'audio': None, 'function_call': None}
|
|
if full_reasoning:
|
|
msg['reasoning_content'] = full_reasoning
|
|
msg['reasoning'] = full_reasoning
|
|
if tool_calls:
|
|
msg['tool_calls'] = tool_calls
|
|
if finish_reason is None:
|
|
finish_reason = 'tool_calls' if tool_calls else 'stop'
|
|
|
|
self._send_json(200, {
|
|
'id': gen_id, 'object': 'chat.completion', 'created': int(time.time()), 'model': model,
|
|
'system_fingerprint': None, 'service_tier': None,
|
|
'choices': [{'index': 0, 'message': msg, 'logprobs': None,
|
|
'finish_reason': finish_reason}],
|
|
'usage': usage or dict(ZERO_USAGE),
|
|
})
|
|
log_done(f'{model} | {len(full_text)} text / {len(full_reasoning)} reasoning / '
|
|
f'{len(tool_calls)} tools | {finish_reason} | '
|
|
f'{self._usage_note(usage)}{int((time.time() - t0) * 1000)}ms')
|
|
|
|
@staticmethod
|
|
def _usage_note(usage):
|
|
if not usage:
|
|
return ''
|
|
cached = (usage.get('prompt_tokens_details') or {}).get('cached_tokens')
|
|
note = f'{usage["prompt_tokens"]}in/{usage["completion_tokens"]}out'
|
|
if cached:
|
|
note += f' ({cached} cached)'
|
|
return note + ' | '
|
|
|
|
def _stream_response(self, resp, model, gen_id, t0, dump, include_usage=False):
|
|
tool_calls, tool_idx = [], 0
|
|
role_sent = False
|
|
t_chars, r_chars = 0, 0
|
|
error_msg = ''
|
|
usage, finish_reason = None, None
|
|
|
|
def base():
|
|
return {'id': gen_id, 'object': 'chat.completion.chunk',
|
|
'created': int(time.time()), 'model': model}
|
|
|
|
def write(chunk):
|
|
self._begin_stream()
|
|
self._write_sse(chunk)
|
|
|
|
def delta(d, finish=None):
|
|
write({**base(), 'choices': [{'index': 0, 'delta': d, 'finish_reason': finish}]})
|
|
|
|
def ensure_role():
|
|
nonlocal role_sent
|
|
if not role_sent:
|
|
role_sent = True
|
|
delta({'role': 'assistant', 'content': ''})
|
|
|
|
for evt in self._events(resp, dump):
|
|
kind = evt.get('type')
|
|
if kind == 'error':
|
|
err = evt.get('error')
|
|
error_msg = (err or {}).get('message') if isinstance(err, dict) else None
|
|
error_msg = error_msg or _dumps(err)
|
|
self._begin_stream()
|
|
self._write_sse(openai_error(error_msg))
|
|
self._end_stream()
|
|
log(f'[error] {model} | {error_msg}')
|
|
break
|
|
if kind == 'text-start':
|
|
# Only opens the message. Emitting a second role chunk here reads as a new
|
|
# assistant turn, and clearing tool_calls would lose calls made earlier in
|
|
# the same response (finish_reason would come back "stop").
|
|
ensure_role()
|
|
elif kind == 'text-delta':
|
|
if evt.get('text'):
|
|
t_chars += len(evt['text'])
|
|
delta({'content': evt['text']})
|
|
elif kind == 'reasoning-delta':
|
|
if evt.get('text'):
|
|
r_chars += len(evt['text'])
|
|
ensure_role()
|
|
delta({'reasoning_content': evt['text'], 'reasoning': evt['text']})
|
|
elif kind == 'tool-input-start':
|
|
ensure_role()
|
|
tool_idx = len(tool_calls)
|
|
tool_calls.append({'id': evt.get('id'), 'name': evt.get('toolName')})
|
|
delta({'tool_calls': [{'index': tool_idx, 'id': evt.get('id'), 'type': 'function',
|
|
'function': {'name': evt.get('toolName'), 'arguments': ''}}]})
|
|
elif kind == 'tool-input-delta':
|
|
if evt.get('delta') and tool_idx < len(tool_calls):
|
|
delta({'tool_calls': [{'index': tool_idx,
|
|
'function': {'arguments': evt['delta']}}]})
|
|
elif kind == 'finish':
|
|
usage = usage_from_finish(evt) or usage
|
|
finish_reason = finish_reason_from(evt, bool(tool_calls)) or finish_reason
|
|
break # nothing meaningful follows the finish event
|
|
# skipped: start, start-step, text-end, reasoning-start/end,
|
|
# tool-input-end, tool-call, finish-step, provider-metadata
|
|
|
|
if error_msg:
|
|
return # already reported on the wire
|
|
|
|
reason = finish_reason or ('tool_calls' if tool_calls else 'stop')
|
|
final = {**base(), 'choices': [{'index': 0, 'delta': {}, 'logprobs': None,
|
|
'finish_reason': reason}]}
|
|
if include_usage:
|
|
final['usage'] = None
|
|
write(final)
|
|
if include_usage:
|
|
# OpenAI's include_usage sends one extra chunk with empty choices and the totals.
|
|
write({**base(), 'choices': [], 'usage': usage or dict(ZERO_USAGE)})
|
|
self._write_chunk(b'data: [DONE]\n\n')
|
|
self._end_stream()
|
|
log(f'[done] {model} | {t_chars} text / {r_chars} reasoning / {len(tool_calls)} tools | '
|
|
f'{reason} | {self._usage_note(usage)}{int((time.time() - t0) * 1000)}ms')
|
|
|
|
|
|
# -- start --------------------------------------------------------------------
|
|
|
|
def _pids_on_port(port):
|
|
"""PIDs listening on `port`, without shelling out to a pipeline."""
|
|
pids = []
|
|
if os.name == 'nt':
|
|
out = subprocess.run(['netstat', '-ano'], capture_output=True, timeout=5).stdout
|
|
for line in out.decode('utf-8', 'ignore').splitlines():
|
|
parts = line.split()
|
|
if len(parts) < 5 or parts[3] != 'LISTENING':
|
|
continue
|
|
local = parts[1]
|
|
if local.rsplit(':', 1)[-1] == str(port):
|
|
pids.append(parts[4])
|
|
else:
|
|
out = subprocess.run(['lsof', '-ti', f'tcp:{port}'], capture_output=True, timeout=5).stdout
|
|
pids = [p for p in out.decode('utf-8', 'ignore').split() if p]
|
|
return pids
|
|
|
|
|
|
def kill_port(port):
|
|
try:
|
|
pids = _pids_on_port(port)
|
|
except Exception:
|
|
return # no netstat/lsof, or nothing listening
|
|
for pid in pids:
|
|
if pid == str(os.getpid()):
|
|
continue
|
|
log(f'killing existing process on port {port} (PID {pid})')
|
|
try:
|
|
if os.name == 'nt':
|
|
subprocess.run(['taskkill', '/F', '/PID', pid], capture_output=True, timeout=5)
|
|
else:
|
|
os.kill(int(pid), signal.SIGKILL)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def main():
|
|
if os.name == 'nt':
|
|
os.system('') # enable VT escape sequences in legacy consoles
|
|
for stream in (sys.stdout, sys.stderr):
|
|
try:
|
|
stream.reconfigure(encoding='utf-8', errors='replace')
|
|
except (AttributeError, ValueError):
|
|
pass # already utf-8, or a stream that cannot be reconfigured
|
|
|
|
banner()
|
|
if CONFIG_NOTE:
|
|
log(CONFIG_NOTE)
|
|
log(f'=== proxy started (debug: {"ON" if DEBUG else "OFF"}) ===')
|
|
|
|
kill_port(PORT)
|
|
|
|
try:
|
|
server = ThreadingHTTPServer(('', PORT), ProxyHandler)
|
|
except OSError as e:
|
|
if e.errno in (48, 98, 10048): # EADDRINUSE
|
|
log_err(f'Port {PORT} still in use after kill attempt')
|
|
sys.exit(1)
|
|
raise
|
|
server.daemon_threads = True
|
|
server.timeout = TIMEOUT
|
|
|
|
def shutdown(signum, frame):
|
|
log('shutting down...')
|
|
threading.Thread(target=server.shutdown, daemon=True).start()
|
|
|
|
for signame in ('SIGINT', 'SIGTERM', 'SIGBREAK'):
|
|
sig = getattr(signal, signame, None)
|
|
if sig is None:
|
|
continue
|
|
try:
|
|
signal.signal(sig, shutdown)
|
|
except (OSError, ValueError):
|
|
pass
|
|
|
|
log(f'listening on http://localhost:{PORT}')
|
|
try:
|
|
server.serve_forever()
|
|
finally:
|
|
server.server_close()
|
|
_log_file.close()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|