"""
NEOME Bridge Client

Run examples:

	python bridgeclient.py --url http://127.0.0.1:11434 --backend ollama
	python bridgeclient.py --url http://127.0.0.1:1234 --backend lmstudio
	python bridgeclient.py --url http://127.0.0.1:8000 --backend vllm
	python bridgeclient.py --url http://127.0.0.1:8080 --backend localai
	python bridgeclient.py --url http://127.0.0.1:5000 --backend tgwui
	python bridgeclient.py --url http://127.0.0.1:5001 --backend koboldcpp
	python bridgeclient.py --url http://127.0.0.1:7860 --backend gradio
	python bridgeclient.py --url http://127.0.0.1:7861 --backend gradio
	python bridgeclient.py --url http://127.0.0.1:8188 --backend comfyui
	python bridgeclient.py --url http://127.0.0.1:3000 --backend custom
	python bridgeclient.py --url http://127.0.0.1:6006 --backend custom

If --backend is omitted, the script tries to auto-detect the backend.

Public API endpoint used by apps:

	https://bridge.neome.com/v1

Use the API key printed by this script:

	Authorization: Bearer sk_neome_xxxxx

OpenAI-style public routes:

	GET  /v1/models
	POST /v1/chat/completions
	POST /v1/completions
	POST /v1/embeddings
	POST /v1/images/generations
	POST /v1/images/edits
	POST /v1/images/variations
	POST /v1/audio/transcriptions
	POST /v1/audio/translations
	POST /v1/audio/speech
	POST /v1/videos/generations
	POST /v1/videos/edits

Custom backend:

	python bridgeclient.py --url http://127.0.0.1:3000 --backend custom

For custom backends, the bridge sends the original JSON payload and also adds:

	path = requested OpenAI-style path
	type = action name, such as chat, image, tts, stt, video

Example custom image request received by your local server:

	{
		"prompt": "a cute monkey",
		"path": "/v1/images/generations",
		"type": "image"
	}

The local backend only needs to return JSON. The bridge forwards it back to the public API response.
"""

import argparse
import asyncio
import json
import os
import secrets
import sys
import time
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
from urllib.request import Request, urlopen

import websockets

BRIDGE_WS = "wss://bridge.neome.com/ws"
PUBLIC_ENDPOINT = "https://bridge.neome.com/v1"
CONFIG_FILE = "bridge.json"

HTTP_TIMEOUT = 600
DETECT_TIMEOUT = 3
MAX_BODY_BYTES = 500 * 1024 * 1024

DEFAULT_URLS = [
	"http://127.0.0.1:11434",
	"http://127.0.0.1:1234",
	"http://127.0.0.1:8000",
	"http://127.0.0.1:8080",
	"http://127.0.0.1:5000",
	"http://127.0.0.1:5001",
	"http://127.0.0.1:7860",
	"http://127.0.0.1:7861",
	"http://127.0.0.1:8188",
	"http://127.0.0.1:3000",
	"http://127.0.0.1:6006",
]

BACKENDS = [
	"openai",
	"ollama",
	"a1111",
	"comfyui",
	"gradio",
	"omni",
	"piper",
	"kokoro",
	"coqui",
	"whisper",
	"localai",
	"lmstudio",
	"vllm",
	"llamacpp",
	"tgwui",
	"koboldcpp",
	"invokeai",
	"forge",
	"sdnext",
	"swarmui",
	"flux",
	"custom",
]


ACTIONS = {
	"/v1/models": "models",
	"/v1/chat/completions": "chat",
	"/v1/completions": "completion",
	"/v1/embeddings": "embedding",
	"/v1/images/generations": "image",
	"/v1/images/edits": "image_edit",
	"/v1/images/variations": "image_variation",
	"/v1/audio/transcriptions": "stt",
	"/v1/audio/translations": "translation",
	"/v1/audio/speech": "tts",
	"/v1/videos/generations": "video",
	"/v1/videos/edits": "video_edit",
}

ACTION_PATHS = {v: k for k, v in ACTIONS.items()}

ROUTES = {
	"/v1/models": {
		"*": "/v1/models",
		"ollama": "/api/tags",
		"a1111": "/sdapi/v1/options",
		"forge": "/sdapi/v1/options",
		"sdnext": "/sdapi/v1/options",
		"comfyui": "/system_stats",
		"gradio": "/config",
		"omni": "/",
		"piper": "/",
		"whisper": "/models",
		"custom": "/",
	},
	"/v1/chat/completions": {
		"*": "/v1/chat/completions",
		"ollama": "/api/chat",
		"gradio": "/api/predict",
		"comfyui": "/prompt",
		"custom": "/",
	},
	"/v1/completions": {
		"*": "/v1/completions",
		"ollama": "/api/generate",
		"gradio": "/api/predict",
		"custom": "/",
	},
	"/v1/embeddings": {
		"*": "/v1/embeddings",
		"ollama": "/api/embeddings",
	},
	"/v1/images/generations": {
		"openai": "/v1/images/generations",
		"localai": "/v1/images/generations",
		"lmstudio": "/v1/images/generations",
		"vllm": "/v1/images/generations",
		"llamacpp": "/v1/images/generations",
		"flux": "/v1/images/generations",
		"a1111": "/sdapi/v1/txt2img",
		"forge": "/sdapi/v1/txt2img",
		"sdnext": "/sdapi/v1/txt2img",
		"invokeai": "/api/v1/images",
		"swarmui": "/GenerateText2Image",
		"gradio": "/api/predict",
		"comfyui": "/prompt",
		"custom": "/",
	},
	"/v1/images/edits": {
		"openai": "/v1/images/edits",
		"localai": "/v1/images/edits",
		"flux": "/v1/images/edits",
		"a1111": "/sdapi/v1/img2img",
		"forge": "/sdapi/v1/img2img",
		"sdnext": "/sdapi/v1/img2img",
		"gradio": "/api/predict",
		"comfyui": "/prompt",
		"custom": "/",
	},
	"/v1/images/variations": {
		"openai": "/v1/images/variations",
		"localai": "/v1/images/variations",
		"a1111": "/sdapi/v1/img2img",
		"forge": "/sdapi/v1/img2img",
		"sdnext": "/sdapi/v1/img2img",
		"gradio": "/api/predict",
		"comfyui": "/prompt",
		"custom": "/",
	},
	"/v1/audio/transcriptions": {
		"openai": "/v1/audio/transcriptions",
		"localai": "/v1/audio/transcriptions",
		"whisper": "/inference",
		"gradio": "/api/predict",
		"comfyui": "/prompt",
		"custom": "/",
	},
	"/v1/audio/translations": {
		"openai": "/v1/audio/translations",
		"localai": "/v1/audio/translations",
		"whisper": "/inference",
		"gradio": "/api/predict",
		"comfyui": "/prompt",
		"custom": "/",
	},
	"/v1/audio/speech": {
		"openai": "/v1/audio/speech",
		"localai": "/v1/audio/speech",
		"kokoro": "/v1/audio/speech",
		"omni": "/tts_to_audio/",
		"piper": "/tts",
		"coqui": "/api/tts",
		"gradio": "/api/predict",
		"comfyui": "/prompt",
		"custom": "/",
	},
	"/v1/videos/generations": {
		"openai": "/v1/videos/generations",
		"localai": "/v1/videos/generations",
		"wan": "/videos/generations",
		"gradio": "/api/predict",
		"comfyui": "/prompt",
		"custom": "/",
	},
	"/v1/videos/edits": {
		"openai": "/v1/videos/edits",
		"wan": "/videos/edits",
		"gradio": "/api/predict",
		"comfyui": "/prompt",
		"custom": "/",
	},
}

OPENAI_BACKENDS = {
	"openai",
	"localai",
	"lmstudio",
	"vllm",
	"llamacpp",
	"tgwui",
	"koboldcpp",
	"kokoro",
	"invokeai",
	"swarmui",
}

A1111_BACKENDS = {
	"a1111",
	"forge",
	"sdnext",
}

running = True


def now():
	return int(time.time())


def make_key():
	return "sk_neome_" + secrets.token_urlsafe(32)


def load_config():
	if not os.path.exists(CONFIG_FILE):
		return {}

	try:
		with open(CONFIG_FILE, "r", encoding="utf-8") as f:
			data = json.load(f)

		return data if isinstance(data, dict) else {}

	except Exception:
		return {}


def save_config(config):
	tmp = CONFIG_FILE + ".tmp"

	with open(tmp, "w", encoding="utf-8") as f:
		json.dump(config, f, indent=2)

	os.replace(tmp, CONFIG_FILE)


def safe_url(url):
	p = urlparse(url)

	if p.scheme not in ("http", "https"):
		raise ValueError("URL must start with http:// or https://")

	if not p.hostname:
		raise ValueError("URL is missing hostname")

	return url.rstrip("/")


def build_url(base_url, path):
	base_url = safe_url(base_url)
	p = urlparse(base_url)

	origin = f"{p.scheme}://{p.netloc}"
	base_path = p.path.rstrip("/")
	path = "/" + str(path or "").lstrip("/")

	if base_path and path.startswith(base_path + "/"):
		return origin + path

	return base_url + path


def http_json(method, base_url, path, payload=None, timeout=HTTP_TIMEOUT):
	base_url = safe_url(base_url)
	data = None

	headers = {
		"Content-Type": "application/json",
		"Accept": "application/json",
	}

	if payload is not None:
		data = json.dumps(payload).encode("utf-8")

	req = Request(
		build_url(base_url, path),
		data=data,
		headers=headers,
		method=method.upper(),
	)

	try:
		with urlopen(req, timeout=timeout) as r:
			raw = r.read(MAX_BODY_BYTES + 1)

			if len(raw) > MAX_BODY_BYTES:
				raise RuntimeError("Local API response too large")

			if not raw:
				return {}

			text = raw.decode("utf-8", errors="replace")

			try:
				return json.loads(text)
			except Exception:
				return {
					"raw": text,
				}

	except HTTPError as e:
		raw = e.read(MAX_BODY_BYTES).decode("utf-8", errors="replace")

		try:
			body = json.loads(raw)
		except Exception:
			body = {
				"error": {
					"message": raw or str(e),
					"type": "local_http_error",
					"status": e.code,
				}
			}

		raise RuntimeError(json.dumps(body))

	except URLError as e:
		raise RuntimeError(str(e.reason))


def http_get(url, path, timeout=DETECT_TIMEOUT):
	return http_json("GET", url, path, None, timeout)


def http_post(url, path, payload, timeout=HTTP_TIMEOUT):
	return http_json("POST", url, path, payload, timeout)


def detect_ollama(url):
	data = http_get(url, "/api/tags")
	models = [m.get("name") for m in data.get("models", []) if m.get("name")]

	return {
		"backend": "ollama",
		"url": url,
		"models": models,
		"default_model": models[0] if models else "default",
	}


def detect_a1111(url, backend="a1111"):
	http_get(url, "/sdapi/v1/options")

	return {
		"backend": backend,
		"url": url,
		"models": ["default"],
		"default_model": "default",
	}


def detect_comfyui(url):
	http_get(url, "/system_stats")

	return {
		"backend": "comfyui",
		"url": url,
		"models": ["default"],
		"default_model": "default",
	}


def detect_gradio(url):
	data = http_get(url, "/config")

	if not isinstance(data, dict):
		raise RuntimeError("Not Gradio")

	if "dependencies" not in data and "components" not in data:
		raise RuntimeError("Not Gradio")

	return {
		"backend": "gradio",
		"url": url,
		"models": ["default"],
		"default_model": "default",
		"config": data,
	}


def detect_omni(url):
	data = http_get(url, "/")

	text = json.dumps(data).lower()

	if "omnivoice" not in text and "tts_to_audio" not in text:
		raise RuntimeError("Not OmniVoice")

	return {
		"backend": "omni",
		"url": url,
		"models": ["omnivoice"],
		"default_model": "omnivoice",
	}

def detect_piper(url):
	data = http_get(url, "/")

	text = json.dumps(data).lower()

	if "piper" not in text and "tts" not in text:
		raise RuntimeError("Not Piper")

	return {
		"backend": "piper",
		"url": url,
		"models": ["piper"],
		"default_model": "piper",
	}


def detect_whisper(url):
	try:
		data = http_get(url, "/models")
	except Exception:
		data = http_get(url, "/")

	text = json.dumps(data).lower()

	if "whisper" not in text and "transcription" not in text:
		raise RuntimeError("Not Whisper")

	return {
		"backend": "whisper",
		"url": url,
		"models": ["whisper"],
		"default_model": "whisper",
	}


def detect_openai(url, backend="openai"):
	data = http_get(url, "/v1/models")
	models = [m.get("id") for m in data.get("data", []) if m.get("id")]

	return {
		"backend": backend,
		"url": url,
		"models": models,
		"default_model": models[0] if models else "default",
	}


def detect_custom(url):
	try:
		http_get(url, "/")
	except Exception:
		http_get(url, "/health")

	return {
		"backend": "custom",
		"url": url,
		"models": ["default"],
		"default_model": "default",
	}


def guess_backend_from_url(url):
	p = urlparse(url)
	port = str(p.port or "")
	host = str(p.hostname or "").lower()

	if "omni" in host:
		return "omni"

	if "image.neome.com" in host:
		return "openai"

	if port == "11434":
		return "ollama"

	if port == "1234":
		return "lmstudio"

	if port == "8000":
		return "vllm"

	if port == "8080":
		return "localai"

	if port == "5000":
		return "tgwui"

	if port == "5001":
		return "koboldcpp"

	if port in ("7860", "7861"):
		return "gradio"

	if port == "8188":
		return "comfyui"

	return "openai"


def detect_forced(url, forced_backend):
	if forced_backend == "omni":
		return {
			"backend": "omni",
			"url": url,
			"models": ["omnivoice"],
			"default_model": "omnivoice",
		}

	if forced_backend == "ollama":
		return detect_ollama(url)

	if forced_backend == "a1111":
		return detect_a1111(url, "a1111")

	if forced_backend == "forge":
		return detect_a1111(url, "forge")

	if forced_backend == "sdnext":
		return detect_a1111(url, "sdnext")

	if forced_backend == "comfyui":
		return detect_comfyui(url)

	if forced_backend == "gradio":
		return detect_gradio(url)

	if forced_backend == "piper":
		return {
			"backend": "piper",
			"url": url,
			"models": ["piper"],
			"default_model": "piper",
		}

	if forced_backend == "whisper":
		return {
			"backend": "whisper",
			"url": url,
			"models": ["whisper"],
			"default_model": "whisper",
		}

	if forced_backend == "custom":
		return {
			"backend": "custom",
			"url": url,
			"models": ["default"],
			"default_model": "default",
		}

	if forced_backend in BACKENDS:
		return detect_openai(url, forced_backend)

	raise RuntimeError("Unknown backend: " + forced_backend)


def detect_backend(url, forced_backend=""):
	if forced_backend:
		return detect_forced(url, forced_backend)

	guessed = guess_backend_from_url(url)

	if guessed == "omni":
		return {
			"backend": "omni",
			"url": url,
			"models": ["omnivoice"],
			"default_model": "omnivoice",
		}

	if guessed == "ollama":
		try:
			return detect_ollama(url)
		except Exception:
			pass

	if guessed == "gradio":
		try:
			return detect_gradio(url)
		except Exception:
			pass

	if guessed == "comfyui":
		try:
			return detect_comfyui(url)
		except Exception:
			pass

	for fn in (
		detect_omni,
		detect_ollama,
		detect_a1111,
		detect_comfyui,
		detect_gradio,
	):
		try:
			return fn(url)
		except Exception:
			pass

	try:
		return detect_openai(url, guessed)
	except Exception:
		pass

	return None


def auto_detect(custom_url="", forced_backend=""):
	urls = []

	if custom_url:
		urls.append(custom_url.rstrip("/"))

	for url in DEFAULT_URLS:
		url = url.rstrip("/")
		if url not in urls:
			urls.append(url)

	for url in urls:
		if not url:
			continue

		try:
			info = detect_backend(url, forced_backend)

			if info:
				return info

		except Exception:
			pass

	return None


def pick_model(payload, info):
	requested = str(payload.get("model") or "") if isinstance(payload, dict) else ""
	models = info.get("models") or []
	default_model = info.get("default_model") or ""

	if requested and requested != "auto" and requested in models:
		return requested

	if default_model:
		return default_model

	if models:
		return models[0]

	return requested or "default"


def strip_data_url(value):
	value = str(value or "")

	if value.startswith("data:") and "," in value:
		return value.split(",", 1)[1]

	return value


def get_images(payload):
	images = payload.get("image") or payload.get("images") or []

	if isinstance(images, str):
		images = [images]

	return [strip_data_url(x) for x in images if x]


def parse_size(payload):
	size = str(payload.get("size") or "1024x1024").lower()
	width = int(payload.get("width") or 1024)
	height = int(payload.get("height") or 1024)

	if "x" in size:
		try:
			w, h = size.split("x", 1)
			width = int(w)
			height = int(h)
		except Exception:
			pass

	return width, height


def route_for(path, backend):
	items = ROUTES.get(path) or {}

	return items.get(backend) or items.get("*") or ""


def normalize_path(path, body):
	if path in ("/v1", "/v1/"):
		action = str(body.get("type") or "chat")
		return ACTION_PATHS.get(action, "/v1/chat/completions")

	return path


def model_list(info):
	models = info.get("models") or ["default"]

	return {
		"object": "list",
		"data": [
			{
				"id": name,
				"object": "model",
				"created": now(),
				"owned_by": info.get("backend") or "local",
			}
			for name in models
		],
	}


def openai_payload(payload, info):
	if isinstance(payload, dict) and "model" in payload:
		payload = dict(payload)
		payload["model"] = pick_model(payload, info)

	return payload or {}


def ollama_messages(payload):
	messages = payload.get("messages")

	if isinstance(messages, list):
		out = []

		for msg in messages:
			item = dict(msg)
			content = item.get("content")

			if isinstance(content, list):
				texts = []
				images = []

				for part in content:
					if part.get("type") == "text":
						texts.append(str(part.get("text") or ""))

					if part.get("type") == "image_url":
						url = ((part.get("image_url") or {}).get("url") or "")

						if url:
							images.append(strip_data_url(url))

				item["content"] = "\n".join(texts)

				if images:
					item["images"] = images

			out.append(item)

		return out

	prompt = payload.get("prompt") or payload.get("input") or ""

	return [
		{
			"role": "user",
			"content": str(prompt),
		}
	]


def ollama_payload(path, payload, info):
	model = pick_model(payload, info)

	if path == "/v1/chat/completions":
		out = {
			"model": model,
			"messages": ollama_messages(payload),
			"stream": False,
		}

		images = get_images(payload)

		if images:
			for msg in reversed(out["messages"]):
				if msg.get("role") == "user":
					msg["images"] = images
					break

		options = {}

		for key in ("temperature", "top_p", "top_k", "seed", "num_ctx"):
			if key in payload:
				options[key] = payload[key]

		if "max_tokens" in payload:
			options["num_predict"] = payload["max_tokens"]

		if options:
			out["options"] = options

		return out

	if path == "/v1/completions":
		prompt = payload.get("prompt") or payload.get("input") or ""

		if isinstance(prompt, list):
			prompt = "\n".join(str(x) for x in prompt)

		out = {
			"model": model,
			"prompt": str(prompt),
			"stream": False,
		}

		images = get_images(payload)

		if images:
			out["images"] = images

		return out

	if path == "/v1/embeddings":
		return {
			"model": model,
			"prompt": str(payload.get("input") or ""),
		}

	return payload


def a1111_payload(path, payload, info):
	width, height = parse_size(payload)

	if path == "/v1/images/generations":
		return {
			"prompt": payload.get("prompt", ""),
			"negative_prompt": payload.get("negative_prompt", ""),
			"steps": payload.get("steps", 20),
			"width": width,
			"height": height,
			"cfg_scale": payload.get("cfg_scale", 7),
			"seed": payload.get("seed", -1),
			"batch_size": payload.get("n", 1),
		}

	if path in ("/v1/images/edits", "/v1/images/variations"):
		images = get_images(payload)

		return {
			"prompt": payload.get("prompt", ""),
			"negative_prompt": payload.get("negative_prompt", ""),
			"init_images": images,
			"denoising_strength": payload.get("strength", 0.75),
			"steps": payload.get("steps", 20),
			"width": width,
			"height": height,
			"cfg_scale": payload.get("cfg_scale", 7),
			"seed": payload.get("seed", -1),
			"batch_size": payload.get("n", 1),
		}

	return payload


def omni_payload(path, payload, info):
	if path == "/v1/audio/speech":
		return {
			"text": payload.get("input") or payload.get("text") or "",
			"voice": payload.get("voice") or "default",
			"format": payload.get("response_format") or payload.get("format") or "mp3",
		}

	return payload or {}


def piper_payload(path, payload, info):
	if path == "/v1/audio/speech":
		return {
			"text": payload.get("input") or payload.get("text") or "",
			"voice": payload.get("voice") or "default",
		}

	return payload or {}


def gradio_payload(path, payload, info):
	prompt = (
		payload.get("prompt") or
		payload.get("input") or
		payload.get("text") or
		""
	)

	if path == "/v1/chat/completions":
		messages = payload.get("messages") or []

		if messages:
			last = messages[-1]
			prompt = last.get("content") or prompt

			if isinstance(prompt, list):
				parts = []

				for part in prompt:
					if part.get("type") == "text":
						parts.append(str(part.get("text") or ""))

				prompt = "\n".join(parts)

	if path == "/v1/audio/speech":
		prompt = payload.get("input") or payload.get("text") or ""

	return {
		"data": [
			prompt,
		]
	}


def custom_payload(path, payload, info):
	out = dict(payload or {})
	out.setdefault("path", path)
	out.setdefault("type", ACTIONS.get(path, "custom"))

	return out


def convert_payload(path, payload, info):
	backend = info.get("backend")

	if backend == "ollama":
		return ollama_payload(path, payload, info)

	if backend in A1111_BACKENDS:
		return a1111_payload(path, payload, info)

	if backend == "omni":
		return omni_payload(path, payload, info)

	if backend == "piper":
		return piper_payload(path, payload, info)

	if backend == "gradio":
		return gradio_payload(path, payload, info)

	if backend == "custom":
		return custom_payload(path, payload, info)

	if backend in OPENAI_BACKENDS:
		return openai_payload(payload, info)

	return payload or {}


def normalize_gradio_text(data):
	if isinstance(data, dict):
		if "data" in data and isinstance(data["data"], list) and data["data"]:
			return str(data["data"][0])

		if "output" in data:
			return str(data["output"])

		if "text" in data:
			return str(data["text"])

	if isinstance(data, list) and data:
		return str(data[0])

	return str(data)


def convert_response(path, data, info):
	backend = info.get("backend")

	if backend == "ollama":
		if path == "/v1/models":
			models = [m.get("name") for m in data.get("models", []) if m.get("name")]
			info["models"] = models

			return model_list(info)

		if path == "/v1/chat/completions":
			content = ""

			if isinstance(data.get("message"), dict):
				content = data["message"].get("content", "")

			return {
				"id": "chatcmpl_" + secrets.token_hex(12),
				"object": "chat.completion",
				"created": now(),
				"model": info.get("default_model", "default"),
				"choices": [
					{
						"index": 0,
						"message": {
							"role": "assistant",
							"content": content,
						},
						"finish_reason": "stop",
					}
				],
			}

		if path == "/v1/completions":
			return {
				"id": "cmpl_" + secrets.token_hex(12),
				"object": "text_completion",
				"created": now(),
				"model": info.get("default_model", "default"),
				"choices": [
					{
						"text": data.get("response", ""),
						"index": 0,
						"logprobs": None,
						"finish_reason": "stop",
					}
				],
			}

		if path == "/v1/embeddings":
			return {
				"object": "list",
				"data": [
					{
						"object": "embedding",
						"index": 0,
						"embedding": data.get("embedding", []),
					}
				],
			}

	if backend in A1111_BACKENDS:
		if path == "/v1/models":
			return model_list(info)

		if path.startswith("/v1/images/"):
			return {
				"created": now(),
				"data": [
					{
						"b64_json": img,
					}
					for img in data.get("images", [])
				],
			}

	if backend == "gradio":
		text = normalize_gradio_text(data)

		if path == "/v1/chat/completions":
			return {
				"id": "chatcmpl_" + secrets.token_hex(12),
				"object": "chat.completion",
				"created": now(),
				"model": info.get("default_model", "default"),
				"choices": [
					{
						"index": 0,
						"message": {
							"role": "assistant",
							"content": text,
						},
						"finish_reason": "stop",
					}
				],
			}

		if path == "/v1/completions":
			return {
				"id": "cmpl_" + secrets.token_hex(12),
				"object": "text_completion",
				"created": now(),
				"model": info.get("default_model", "default"),
				"choices": [
					{
						"text": text,
						"index": 0,
						"logprobs": None,
						"finish_reason": "stop",
					}
				],
			}

	if backend in ("omni", "piper") and path == "/v1/audio/speech":
		return data

	if path == "/v1/models":
		return model_list(info)

	return data


def unsupported(path, backend):
	return {
		"error": {
			"message": "Route not supported by this backend.",
			"type": "unsupported_route",
			"path": path,
			"backend": backend,
		}
	}


def handle_request(info, req):
	method = str(req.get("method") or "POST").upper()
	body = req.get("body") or {}
	path = normalize_path(str(req.get("path") or ""), body)
	backend = info.get("backend")
	local_route = route_for(path, backend)

	if not local_route:
		return unsupported(path, backend)

	if path == "/v1/models" and backend in A1111_BACKENDS.union({"comfyui", "omni", "piper", "gradio", "custom"}):
		return model_list(info)

	payload = convert_payload(path, body, info)

	if path == "/v1/models":
		data = http_get(info["url"], local_route, HTTP_TIMEOUT)
	elif method == "GET":
		data = http_get(info["url"], local_route, HTTP_TIMEOUT)
	else:
		data = http_post(info["url"], local_route, payload, HTTP_TIMEOUT)

	return convert_response(path, data, info)


async def send_json(ws, data):
	await ws.send(json.dumps(data, separators=(",", ":")))


async def register(ws, key):
	await send_json(ws, {
		"type": "register",
		"key": key,
	})


async def process_message(ws, info, message):
	try:
		req = json.loads(message)
	except Exception:
		return

	if req.get("type") != "request":
		return

	request_id = req.get("request_id")

	try:
		body = await asyncio.to_thread(handle_request, info, req)
		status = 200

		if isinstance(body, dict) and "error" in body:
			status = 400

		await send_json(ws, {
			"type": "response",
			"request_id": request_id,
			"status": status,
			"body": body,
		})

	except Exception as e:
		await send_json(ws, {
			"type": "response",
			"request_id": request_id,
			"status": 500,
			"body": {
				"error": {
					"message": str(e),
					"type": "bridge_error",
				}
			},
		})


async def connect_loop(ws_url, key, info):
	global running
	backoff = 1

	while running:
		try:
			print("Connecting...", flush=True)

			async with websockets.connect(
				ws_url,
				ping_interval=25,
				ping_timeout=10,
				max_size=None,
				close_timeout=5,
			) as ws:
				await register(ws, key)
				backoff = 1

				print("Connected ✓")
				print("")
				print("API KEY:")
				print(key)
				print("")
				print("Endpoint:")
				print(PUBLIC_ENDPOINT)
				print("")
				print("Detected:")
				print(f"{info.get('backend')} ({info.get('url')})")
				print("")
				print("Default model:")
				print(info.get("default_model") or "default")
				print("")
				print("Ready.", flush=True)

				async for message in ws:
					await process_message(ws, info, message)

		except Exception as e:
			if running:
				print("Disconnected:", str(e), flush=True)
				print(f"Reconnecting in {backoff}s...", flush=True)
				await asyncio.sleep(backoff)
				backoff = min(backoff * 2, 30)


async def main():
	parser = argparse.ArgumentParser()
	parser.add_argument("--url", default="", help="Local API URL")
	parser.add_argument("--key", default="", help="Existing bridge key")
	parser.add_argument("--ws", default=BRIDGE_WS, help="Bridge WebSocket URL")
	parser.add_argument("--backend", default="", help="Force backend name")
	args = parser.parse_args()

	config = load_config()
	loaded_config = bool(config.get("key"))
	key = (
		args.key
		or config.get("key")
		or make_key()
	)
	local_url = args.url or ""
	forced_backend = args.backend or ""

	try:
		if local_url:
			local_url = safe_url(local_url)
	except Exception as e:
		print("Invalid URL:", e, flush=True)
		sys.exit(1)

	info = auto_detect(local_url, forced_backend)

	if not info:
		print("No local AI backend found.")
		print("Try one of:")

		for url in DEFAULT_URLS:
			print(url)

		print("")
		print("Or run:")
		print("python bridgeclient.py --url http://127.0.0.1:11434 --backend ollama")
		print("python bridgeclient.py --url https://omni.neome.com --backend omni")
		print("python bridgeclient.py --url http://127.0.0.1:7860 --backend gradio")
		sys.exit(1)

	config["key"] = key
	config["url"] = info["url"]
	config["backend"] = info["backend"]
	save_config(config)

	print("=========================")
	print("NEOME BRIDGE")
	print("=========================")
	if loaded_config:
		print("Loaded config: bridge.json")
	else:
		print("Created new bridge.json")
	print("")
	print("Config file:")
	print(os.path.abspath(CONFIG_FILE))
	print("")
	await connect_loop(args.ws, key, info)


if __name__ == "__main__":
	try:
		asyncio.run(main())
	except KeyboardInterrupt:
		running = False
