feat: expand CoderAI broker protocol support

parent 42b07048
...@@ -1370,16 +1370,18 @@ AISBF now includes an AISBF-side broker for NAT-friendly CoderAI integration. ...@@ -1370,16 +1370,18 @@ AISBF now includes an AISBF-side broker for NAT-friendly CoderAI integration.
- `GET /api/coderai/broker/sessions` - list active broker sessions - `GET /api/coderai/broker/sessions` - list active broker sessions
- `GET /api/coderai/broker/providers/{provider_id}/status` - inspect active session status for a provider/client - `GET /api/coderai/broker/providers/{provider_id}/status` - inspect active session status for a provider/client
- `WS /api/coderai/broker/ws` - persistent outbound WebSocket endpoint used by CoderAI behind NAT - `WS /api/coderai/wss` - persistent outbound WebSocket endpoint for global-scope CoderAI clients
- `WS /api/u/{username}/coderai/wss` - persistent outbound WebSocket endpoint for user-owned CoderAI clients
### Broker Request Flow ### Broker Request Flow
1. CoderAI opens outbound WebSocket to AISBF broker 1. CoderAI opens outbound WebSocket to AISBF broker
2. AISBF registers a live session keyed by `provider_id` and `client_id` 2. AISBF registers a scoped live session keyed by `provider_id` and `client_id`, plus `username` / `global` scope
3. CoderAI sends `register` metadata/capabilities over that socket 3. CoderAI sends `register` metadata/capabilities over that socket
4. `CoderAIProviderHandler` prefers the live broker session when `coderai_config.broker_preferred=true` 4. `CoderAIProviderHandler` prefers the live broker session when `coderai_config.broker_preferred=true`
5. AISBF forwards `models.list`, `chat.completions`, `capabilities`, `register`, and `proxy` operations through the active session 5. In multi-node deployments, AISBF forwards `models.list`, `chat.completions`, `capabilities`, `register`, and `proxy` operations through cache-backed broker queues keyed by the active session id
6. Response envelopes are correlated by `request_id` and returned to the original AISBF request 6. The AISBF node holding the actual WebSocket consumes queued requests, sends them to CoderAI, and publishes replies back through cache-backed reply keys
7. Response envelopes are correlated by `request_id` and returned to the original AISBF request from any AISBF node
### Configuration Notes ### Configuration Notes
...@@ -1408,7 +1410,7 @@ Token rotation is performed inline from the provider editor and updates the owni ...@@ -1408,7 +1410,7 @@ Token rotation is performed inline from the provider editor and updates the owni
### Important Limitation ### Important Limitation
Active broker routing still requires a live WebSocket, but AISBF now persists broker session metadata snapshots in `~/.aisbf/coderai_broker_sessions.json` so the dashboard can show last-known broker status after restart. Remote CoderAI must still reconnect before requests can be routed again. Active broker routing still requires a live WebSocket, but AISBF now persists broker session metadata snapshots in `~/.aisbf/coderai_broker_sessions.json` and mirrors live broker state into the configured cache backend so clustered AISBF nodes can route through one connected broker client. Remote CoderAI must still reconnect before requests can be routed again after a total disconnect.
### Security Considerations ### Security Considerations
......
include README.md include README.md
include LICENSE.txt include LICENSE.txt
include DOCUMENTATION.md include DOCUMENTATION.md
include AI.PROMPT
include requirements.txt include requirements.txt
include main.py include main.py
include pyproject.toml include pyproject.toml
include aisbf.sh include aisbf.sh
include cli.py include cli.py
recursive-include docs *.md
recursive-include config *.json recursive-include config *.json
recursive-include config *.md recursive-include config *.md
recursive-include aisbf *.py recursive-include aisbf *.py
...@@ -17,4 +19,4 @@ recursive-include templates *.js ...@@ -17,4 +19,4 @@ recursive-include templates *.js
recursive-include static *.zip recursive-include static *.zip
recursive-include static *.js recursive-include static *.js
recursive-include static/i18n *.json recursive-include static/i18n *.json
recursive-include static/extension *.js *.json *.html *.md *.png *.svg recursive-include static/extension *.js *.json *.html *.md *.png *.svg
\ No newline at end of file
...@@ -52,6 +52,7 @@ def _cache_decode(data: bytes) -> any: ...@@ -52,6 +52,7 @@ def _cache_decode(data: bytes) -> any:
from typing import Any, Optional, Dict, List from typing import Any, Optional, Dict, List
from pathlib import Path from pathlib import Path
import time import time
import uuid
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
...@@ -850,6 +851,94 @@ class CacheManager: ...@@ -850,6 +851,94 @@ class CacheManager:
"""Clear all cache entries""" """Clear all cache entries"""
self.backend.clear() self.backend.clear()
def broker_backend_type(self) -> str:
return self.cache_type.lower()
def broker_supports_distributed(self) -> bool:
return self.broker_backend_type() in {'redis', 'sqlite', 'mysql'}
def broker_key(self, suffix: str) -> str:
prefix = self.config.get('redis_key_prefix', 'aisbf:') if isinstance(self.config, dict) else 'aisbf:'
return f"{prefix}broker:{suffix}"
def broker_set(self, key: str, value: Any, ttl: Optional[int] = None) -> None:
self.set(self.broker_key(key), value, ttl=ttl)
def broker_get(self, key: str) -> Optional[Any]:
return self.get(self.broker_key(key))
def broker_delete(self, key: str) -> None:
self.delete(self.broker_key(key))
def broker_blocking_pop(self, key: str, timeout: int = 1) -> Optional[Any]:
backend_type = self.broker_backend_type()
if backend_type == 'redis' and hasattr(self.backend, 'redis'):
item = self.backend.redis.blpop(self.broker_key(key), timeout=timeout)
if not item:
return None
return _cache_decode(item[1])
deadline = time.time() + max(timeout, 0)
while True:
item = self.broker_pop_nowait(key)
if item is not None:
return item
if time.time() >= deadline:
return None
time.sleep(0.1)
def broker_push(self, key: str, value: Any, ttl: Optional[int] = None) -> None:
backend_type = self.broker_backend_type()
if backend_type == 'redis' and hasattr(self.backend, 'redis'):
redis_key = self.broker_key(key)
self.backend.redis.rpush(redis_key, _cache_encode(value))
if ttl:
self.backend.redis.expire(redis_key, ttl)
return
queue = self.broker_get(key) or []
if not isinstance(queue, list):
queue = []
queue.append(value)
self.broker_set(key, queue, ttl=ttl)
def broker_pop_nowait(self, key: str) -> Optional[Any]:
backend_type = self.broker_backend_type()
if backend_type == 'redis' and hasattr(self.backend, 'redis'):
item = self.backend.redis.lpop(self.broker_key(key))
if item is None:
return None
return _cache_decode(item)
queue = self.broker_get(key)
if not isinstance(queue, list) or not queue:
return None
item = queue.pop(0)
if queue:
self.broker_set(key, queue)
else:
self.broker_delete(key)
return item
def broker_list_keys(self, pattern: str) -> list[str]:
backend_type = self.broker_backend_type()
full_pattern = self.broker_key(pattern)
if backend_type == 'redis' and hasattr(self.backend, 'redis'):
keys = self.backend.redis.keys(full_pattern)
decoded = []
for key in keys:
if isinstance(key, bytes):
key = key.decode('utf-8')
decoded.append(str(key))
return decoded
return []
def broker_node_id(self) -> str:
key = '__broker_node_id__'
existing = self.broker_get(key)
if existing:
return str(existing)
node_id = str(uuid.uuid4())
self.broker_set(key, node_id)
return node_id
# Numpy-specific methods # Numpy-specific methods
def save_numpy_array(self, key: str, array: Any, metadata: Optional[Dict] = None) -> None: def save_numpy_array(self, key: str, array: Any, metadata: Optional[Dict] = None) -> None:
"""Save numpy array (fallback to file-based even with Redis)""" """Save numpy array (fallback to file-based even with Redis)"""
...@@ -1676,4 +1765,4 @@ def initialize_response_cache(config: Optional[Dict] = None): ...@@ -1676,4 +1765,4 @@ def initialize_response_cache(config: Optional[Dict] = None):
"""Initialize the response cache system""" """Initialize the response cache system"""
global _response_cache global _response_cache
_response_cache = ResponseCache(config) _response_cache = ResponseCache(config)
logger.info("Response cache initialized") logger.info("Response cache initialized")
\ No newline at end of file
...@@ -15,9 +15,16 @@ from typing import Any, Dict, Optional ...@@ -15,9 +15,16 @@ from typing import Any, Dict, Optional
from fastapi import WebSocket from fastapi import WebSocket
from .cache import get_cache_manager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SESSION_TTL_SECONDS = 120
REQUEST_TTL_SECONDS = 300
REPLY_TTL_SECONDS = 300
HEARTBEAT_POLL_SECONDS = 1
class BrokerPlaceholderWebSocket: class BrokerPlaceholderWebSocket:
async def send_text(self, payload: str): async def send_text(self, payload: str):
...@@ -28,6 +35,8 @@ class BrokerPlaceholderWebSocket: ...@@ -28,6 +35,8 @@ class BrokerPlaceholderWebSocket:
class PendingCoderAIRequest: class PendingCoderAIRequest:
future: asyncio.Future future: asyncio.Future
created_at: float created_at: float
stream_queue: asyncio.Queue | None = None
event_log: list[Dict[str, Any]] = field(default_factory=list)
@dataclass @dataclass
...@@ -51,12 +60,30 @@ class CoderAIBroker: ...@@ -51,12 +60,30 @@ class CoderAIBroker:
self._pending: Dict[str, PendingCoderAIRequest] = {} self._pending: Dict[str, PendingCoderAIRequest] = {}
self._state_path = Path.home() / '.aisbf' / 'coderai_broker_sessions.json' self._state_path = Path.home() / '.aisbf' / 'coderai_broker_sessions.json'
self._state_path.parent.mkdir(parents=True, exist_ok=True) self._state_path.parent.mkdir(parents=True, exist_ok=True)
self._cache = get_cache_manager()
self._node_id = self._cache.broker_node_id()
self._load_persisted_sessions() self._load_persisted_sessions()
@staticmethod @staticmethod
def _session_key(provider_id: str, client_id: str) -> str: def _session_key(provider_id: str, client_id: str) -> str:
return f"{provider_id}:{client_id}" return f"{provider_id}:{client_id}"
@staticmethod
def _request_queue_key(session_id: str) -> str:
return f"request_queue:{session_id}"
@staticmethod
def _reply_key(request_id: str) -> str:
return f"reply:{request_id}"
@staticmethod
def _session_meta_key(provider_id: str, client_id: str) -> str:
return f"session:{provider_id}:{client_id}"
@staticmethod
def _session_index_key() -> str:
return "session_index"
def _serialize_session(self, session: CoderAISession) -> Dict[str, Any]: def _serialize_session(self, session: CoderAISession) -> Dict[str, Any]:
return { return {
"session_id": session.session_id, "session_id": session.session_id,
...@@ -74,7 +101,6 @@ class CoderAIBroker: ...@@ -74,7 +101,6 @@ class CoderAIBroker:
"sessions": [ "sessions": [
self._serialize_session(session) self._serialize_session(session)
for session in self._sessions_by_id.values() for session in self._sessions_by_id.values()
if not session.closed
], ],
"updated_at": time.time(), "updated_at": time.time(),
} }
...@@ -108,7 +134,7 @@ class CoderAIBroker: ...@@ -108,7 +134,7 @@ class CoderAIBroker:
metadata=dict(raw_session.get("metadata") or {}), metadata=dict(raw_session.get("metadata") or {}),
capabilities=dict(raw_session.get("capabilities") or {}), capabilities=dict(raw_session.get("capabilities") or {}),
last_seen=float(raw_session.get("last_seen") or time.time()), last_seen=float(raw_session.get("last_seen") or time.time()),
closed=True, closed=bool(raw_session.get("closed", True)),
) )
session.metadata.setdefault("persisted", True) session.metadata.setdefault("persisted", True)
session.metadata.setdefault("connection_state", "disconnected") session.metadata.setdefault("connection_state", "disconnected")
...@@ -116,13 +142,36 @@ class CoderAIBroker: ...@@ -116,13 +142,36 @@ class CoderAIBroker:
self._sessions_by_key[key] = session self._sessions_by_key[key] = session
self._sessions_by_id[session_id] = session self._sessions_by_id[session_id] = session
def _store_session_cache(self, session: CoderAISession) -> None:
payload = self._serialize_session(session)
payload["metadata"] = dict(payload.get("metadata") or {})
payload["metadata"]["broker_node_id"] = session.metadata.get("broker_node_id") or self._node_id
session.metadata.setdefault("broker_node_id", self._node_id)
payload["metadata"]["connection_state"] = "connected" if not session.closed else "disconnected"
self._cache.broker_set(self._session_meta_key(session.provider_id, session.client_id), payload, ttl=SESSION_TTL_SECONDS)
index = self._cache.broker_get(self._session_index_key()) or []
if not isinstance(index, list):
index = []
key = self._session_meta_key(session.provider_id, session.client_id)
if key not in index:
index.append(key)
self._cache.broker_set(self._session_index_key(), index, ttl=SESSION_TTL_SECONDS * 10)
def _delete_session_cache(self, provider_id: str, client_id: str) -> None:
key = self._session_meta_key(provider_id, client_id)
self._cache.broker_delete(key)
index = self._cache.broker_get(self._session_index_key()) or []
if isinstance(index, list) and key in index:
index = [item for item in index if item != key]
self._cache.broker_set(self._session_index_key(), index, ttl=SESSION_TTL_SECONDS * 10)
async def register(self, websocket: WebSocket, provider_id: str, client_id: str, metadata: Optional[Dict[str, Any]] = None, capabilities: Optional[Dict[str, Any]] = None, session_id: Optional[str] = None) -> CoderAISession: async def register(self, websocket: WebSocket, provider_id: str, client_id: str, metadata: Optional[Dict[str, Any]] = None, capabilities: Optional[Dict[str, Any]] = None, session_id: Optional[str] = None) -> CoderAISession:
async with self._lock: async with self._lock:
key = self._session_key(provider_id, client_id) key = self._session_key(provider_id, client_id)
existing = self._sessions_by_key.get(key) existing = self._sessions_by_key.get(key)
if existing: if existing:
existing.closed = True existing.closed = True
self._sessions_by_id.pop(existing.session_id, None)
resolved_session_id = session_id or f"coderai_{uuid.uuid4().hex}" resolved_session_id = session_id or f"coderai_{uuid.uuid4().hex}"
session = CoderAISession( session = CoderAISession(
session_id=resolved_session_id, session_id=resolved_session_id,
...@@ -137,6 +186,7 @@ class CoderAIBroker: ...@@ -137,6 +186,7 @@ class CoderAIBroker:
session.metadata["persisted"] = True session.metadata["persisted"] = True
self._sessions_by_key[key] = session self._sessions_by_key[key] = session
self._sessions_by_id[resolved_session_id] = session self._sessions_by_id[resolved_session_id] = session
self._store_session_cache(session)
self._persist_sessions_locked() self._persist_sessions_locked()
logger.info(f"CoderAI broker registered session provider={provider_id} client={client_id} session_id={resolved_session_id}") logger.info(f"CoderAI broker registered session provider={provider_id} client={client_id} session_id={resolved_session_id}")
return session return session
...@@ -152,6 +202,7 @@ class CoderAIBroker: ...@@ -152,6 +202,7 @@ class CoderAIBroker:
session.metadata["connection_state"] = "disconnected" session.metadata["connection_state"] = "disconnected"
session.last_seen = time.time() session.last_seen = time.time()
self._sessions_by_key[key] = session self._sessions_by_key[key] = session
self._store_session_cache(session)
self._persist_sessions_locked() self._persist_sessions_locked()
logger.info(f"CoderAI broker unregistered session provider={session.provider_id} client={session.client_id} session_id={session.session_id}") logger.info(f"CoderAI broker unregistered session provider={session.provider_id} client={session.client_id} session_id={session.session_id}")
...@@ -165,6 +216,7 @@ class CoderAIBroker: ...@@ -165,6 +216,7 @@ class CoderAIBroker:
session.metadata.update(metadata) session.metadata.update(metadata)
if capabilities: if capabilities:
session.capabilities = dict(capabilities) session.capabilities = dict(capabilities)
self._store_session_cache(session)
self._persist_sessions_locked() self._persist_sessions_locked()
return session return session
...@@ -172,17 +224,47 @@ class CoderAIBroker: ...@@ -172,17 +224,47 @@ class CoderAIBroker:
async with self._lock: async with self._lock:
if client_id: if client_id:
session = self._sessions_by_key.get(self._session_key(provider_id, client_id)) session = self._sessions_by_key.get(self._session_key(provider_id, client_id))
return session if session and not session.closed else None if session and not session.closed:
candidates = [session for session in self._sessions_by_key.values() if session.provider_id == provider_id and not session.closed] return session
if not candidates: else:
return None candidates = [session for session in self._sessions_by_key.values() if session.provider_id == provider_id and not session.closed]
candidates.sort(key=lambda session: session.last_seen, reverse=True) if candidates:
return candidates[0] candidates.sort(key=lambda session: session.last_seen, reverse=True)
return candidates[0]
snapshot = await self.get_session_snapshot(provider_id, client_id)
if not snapshot or not snapshot.get("connected"):
return None
return None
async def list_sessions(self) -> list[Dict[str, Any]]: async def list_sessions(self) -> list[Dict[str, Any]]:
cache_sessions = []
index = self._cache.broker_get(self._session_index_key()) or []
if isinstance(index, list):
for key in index:
meta = self._cache.broker_get(key.replace(self._cache.broker_key(''), '')) if False else None
stored = self._cache.get(key) if key.startswith(self._cache.config.get('redis_key_prefix', 'aisbf:')) else self._cache.broker_get(key)
if stored:
cache_sessions.append(stored)
merged: Dict[str, Dict[str, Any]] = {}
for item in cache_sessions:
if not isinstance(item, dict):
continue
merged[item.get("session_id") or f"missing:{uuid.uuid4().hex}"] = {
"session_id": item.get("session_id"),
"provider_id": item.get("provider_id"),
"client_id": item.get("client_id"),
"connected_at": item.get("connected_at"),
"last_seen": item.get("last_seen"),
"metadata": item.get("metadata") or {},
"capabilities": item.get("capabilities") or {},
"connected": not bool(item.get("closed")),
}
async with self._lock: async with self._lock:
return [ for session in self._sessions_by_id.values():
{ merged[session.session_id] = {
"session_id": session.session_id, "session_id": session.session_id,
"provider_id": session.provider_id, "provider_id": session.provider_id,
"client_id": session.client_id, "client_id": session.client_id,
...@@ -192,52 +274,164 @@ class CoderAIBroker: ...@@ -192,52 +274,164 @@ class CoderAIBroker:
"capabilities": dict(session.capabilities), "capabilities": dict(session.capabilities),
"connected": not session.closed, "connected": not session.closed,
} }
for session in self._sessions_by_id.values()
] return list(merged.values())
async def get_session_snapshot(self, provider_id: str, client_id: Optional[str] = None) -> Optional[Dict[str, Any]]: async def get_session_snapshot(self, provider_id: str, client_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
async with self._lock: if client_id:
session = self._sessions_by_key.get(self._session_key(provider_id, client_id)) if client_id else None payload = self._cache.broker_get(self._session_meta_key(provider_id, client_id))
if session is None: if payload:
candidates = [item for item in self._sessions_by_key.values() if item.provider_id == provider_id] payload["connected"] = not bool(payload.get("closed"))
if not candidates: return payload
return None async with self._lock:
candidates.sort(key=lambda item: item.last_seen, reverse=True) local_session = self._sessions_by_key.get(self._session_key(provider_id, client_id))
session = candidates[0] if local_session:
return self._serialize_session(session) | {"connected": not session.closed} return {
"session_id": local_session.session_id,
"provider_id": local_session.provider_id,
"client_id": local_session.client_id,
"connected_at": local_session.connected_at,
"last_seen": local_session.last_seen,
"closed": local_session.closed,
"metadata": dict(local_session.metadata),
"capabilities": dict(local_session.capabilities),
"connected": not local_session.closed,
}
candidates = []
for session in await self.list_sessions():
if session.get("provider_id") == provider_id:
candidates.append(session)
if not candidates:
return None
candidates.sort(key=lambda item: item.get("last_seen") or 0, reverse=True)
return candidates[0]
async def send_request(self, provider_id: str, op: str, payload: Dict[str, Any], timeout: float = 300.0, client_id: Optional[str] = None, owner_user_id: Optional[int] = None, extra: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: async def send_request(self, provider_id: str, op: str, payload: Dict[str, Any], timeout: float = 300.0, client_id: Optional[str] = None, owner_user_id: Optional[int] = None, extra: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
session = await self.get_session(provider_id, client_id) snapshot = await self.get_session_snapshot(provider_id, client_id)
if not session: if not snapshot or not snapshot.get("connected"):
raise RuntimeError(f"No active CoderAI broker session for provider '{provider_id}'") raise RuntimeError(f"No active CoderAI broker session for provider '{provider_id}'")
if owner_user_id != session.metadata.get('owner_user_id'): if owner_user_id != ((snapshot.get('metadata') or {}).get('owner_user_id')):
raise RuntimeError(f"No active CoderAI broker session for provider '{provider_id}' owned by the current principal") raise RuntimeError(f"No active CoderAI broker session for provider '{provider_id}' owned by the current principal")
request_id = f"broker_{uuid.uuid4().hex}" request_id = f"broker_{uuid.uuid4().hex}"
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
future = loop.create_future() future = loop.create_future()
stream_queue = asyncio.Queue() if payload.get("stream") else None
envelope = { envelope = {
"v": 1, "v": 1,
"op": op, "op": op,
"request_id": request_id, "request_id": request_id,
"provider_id": provider_id, "provider_id": provider_id,
"client_id": session.client_id, "client_id": snapshot.get("client_id") or client_id,
"payload": payload, "payload": payload,
"reply_key": self._reply_key(request_id),
"requester_node_id": self._node_id,
} }
if extra: if extra:
envelope.update(extra) envelope.update(extra)
async with self._lock: async with self._lock:
self._pending[request_id] = PendingCoderAIRequest(future=future, created_at=time.time()) self._pending[request_id] = PendingCoderAIRequest(
future=future,
created_at=time.time(),
stream_queue=stream_queue,
)
local_session = None
target_node_id = None
async with self._lock:
local_session = self._sessions_by_id.get(snapshot["session_id"])
local_node_id = local_session.metadata.get("broker_node_id") if local_session else None
target_node_id = ((snapshot.get("metadata") or {}).get("broker_node_id") if snapshot else None)
if target_node_id is None:
target_node_id = local_node_id
use_local_fast_path = bool(
local_session
and not local_session.closed
and (target_node_id is None or target_node_id == self._node_id)
)
try: try:
await session.websocket.send_text(json.dumps(envelope)) if use_local_fast_path:
logger.debug(f"CoderAI broker sent op={op} provider={provider_id} client={session.client_id} request_id={request_id}") await local_session.websocket.send_text(json.dumps(envelope))
return await asyncio.wait_for(future, timeout=timeout) logger.debug(f"CoderAI broker fast-pathed op={op} provider={provider_id} client={snapshot.get('client_id')} request_id={request_id}")
else:
self._cache.broker_push(self._request_queue_key(snapshot["session_id"]), envelope, ttl=REQUEST_TTL_SECONDS)
logger.debug(f"CoderAI broker enqueued op={op} provider={provider_id} client={snapshot.get('client_id')} request_id={request_id}")
async def wait_reply() -> Dict[str, Any]:
if use_local_fast_path:
return await asyncio.wait_for(future, timeout=timeout)
deadline = time.time() + timeout
while time.time() < deadline:
reply = self._cache.broker_get(self._reply_key(request_id))
if reply is not None:
self._cache.broker_delete(self._reply_key(request_id))
return reply
if future.done():
return future.result()
await asyncio.sleep(0.1)
raise asyncio.TimeoutError()
result = await asyncio.wait_for(wait_reply(), timeout=timeout)
if not future.done():
future.set_result(result)
return result
finally: finally:
async with self._lock: async with self._lock:
self._pending.pop(request_id, None) self._pending.pop(request_id, None)
async def consume_request(self, session_id: str, timeout: int = HEARTBEAT_POLL_SECONDS) -> Optional[Dict[str, Any]]:
return await asyncio.to_thread(self._cache.broker_blocking_pop, self._request_queue_key(session_id), timeout)
async def publish_response(self, message: Dict[str, Any]) -> None:
request_id = message.get("request_id")
if not request_id:
return
event = message.get("event")
if event in {"chunk", "progress", "output", "log", "data", "done", "completed"}:
await self._publish_stream_response(message)
return
self._cache.broker_set(self._reply_key(request_id), message, ttl=REPLY_TTL_SECONDS)
await self.resolve_response(message)
async def _publish_stream_response(self, message: Dict[str, Any]) -> None:
request_id = message.get("request_id")
async with self._lock:
pending = self._pending.get(request_id)
queue = pending.stream_queue if pending else None
if queue is not None:
pending.event_log.append(message)
if message.get("event") in {"done", "completed"} and not pending.future.done():
pending.future.set_result(message)
else:
await queue.put(message)
return
self._cache.broker_push(self._reply_key(request_id), message, ttl=REPLY_TTL_SECONDS)
async def wait_for_stream_event(self, request_id: str, timeout: float = 300.0) -> Dict[str, Any]:
async with self._lock:
pending = self._pending.get(request_id)
queue = pending.stream_queue if pending else None
if pending and pending.event_log:
for idx, event in enumerate(pending.event_log):
if event.get("event") not in {"done", "completed"}:
pending.event_log.pop(idx)
return event
if queue is not None:
return await asyncio.wait_for(queue.get(), timeout=timeout)
deadline = time.time() + timeout
while time.time() < deadline:
reply = self._cache.broker_pop_nowait(self._reply_key(request_id))
if reply is not None:
return reply
await asyncio.sleep(0.1)
raise asyncio.TimeoutError()
async def resolve_response(self, message: Dict[str, Any]) -> bool: async def resolve_response(self, message: Dict[str, Any]) -> bool:
request_id = message.get("request_id") request_id = message.get("request_id")
if not request_id: if not request_id:
......
...@@ -47,23 +47,51 @@ def _load_user_provider_config(user_id: int, provider_id: str) -> Optional[Dict[ ...@@ -47,23 +47,51 @@ def _load_user_provider_config(user_id: int, provider_id: str) -> Optional[Dict[
return None return None
def resolve_coderai_provider_owner(provider_id: str) -> Tuple[Optional[int], Optional[Dict[str, Any]]]: def _iter_user_coderai_matches(provider_id: str) -> list[Tuple[int, Dict[str, Any]]]:
global_config = _load_global_provider_config(provider_id)
if global_config and global_config.get('type') == 'coderai':
return None, global_config
try: try:
db = DatabaseRegistry.get_config_database() db = DatabaseRegistry.get_config_database()
if not db: if not db:
return None, None return []
matches: list[Tuple[int, Dict[str, Any]]] = []
for row in db.get_all_user_providers(): for row in db.get_all_user_providers():
if row.get('provider_id') != provider_id: if row.get('provider_id') != provider_id:
continue continue
provider_config = row.get('config') provider_config = row.get('config')
if isinstance(provider_config, dict) and provider_config.get('type') == 'coderai': if isinstance(provider_config, dict) and provider_config.get('type') == 'coderai':
return row.get('user_id'), provider_config user_id = row.get('user_id')
if user_id is not None:
matches.append((user_id, provider_config))
return matches
except Exception as e: except Exception as e:
logger.debug(f"Failed to resolve user owner for provider={provider_id}: {e}") logger.debug(f"Failed to resolve user owner for provider={provider_id}: {e}")
return []
def resolve_coderai_provider_owner(provider_id: str, username: Optional[str] = None) -> Tuple[Optional[int], Optional[Dict[str, Any]]]:
user_matches = _iter_user_coderai_matches(provider_id)
if username and username != 'global':
try:
db = DatabaseRegistry.get_config_database()
user = db.get_user_by_username(username) if db else None
if user:
requested_user_id = user.get('id')
for user_id, provider_config in user_matches:
if user_id == requested_user_id:
return user_id, provider_config
return None, None
except Exception as e:
logger.debug(f"Failed to resolve user by username for provider={provider_id} username={username}: {e}")
return None, None
if user_matches:
if len(user_matches) == 1:
return user_matches[0]
logger.warning(f"Ambiguous user-scoped coderai provider owner for provider_id={provider_id}; refusing global fallback")
return None, None
global_config = _load_global_provider_config(provider_id)
if global_config and global_config.get('type') == 'coderai':
return None, global_config
return None, None return None, None
...@@ -80,8 +108,8 @@ def resolve_coderai_provider_for_user(user_id: Optional[int], provider_id: str) ...@@ -80,8 +108,8 @@ def resolve_coderai_provider_for_user(user_id: Optional[int], provider_id: str)
return None return None
def resolve_coderai_registration(provider_id: str) -> Tuple[Optional[int], Optional[Dict[str, Any]], Optional[str]]: def resolve_coderai_registration(provider_id: str, username: Optional[str] = None) -> Tuple[Optional[int], Optional[Dict[str, Any]], Optional[str]]:
owner_user_id, provider_config = resolve_coderai_provider_owner(provider_id) owner_user_id, provider_config = resolve_coderai_provider_owner(provider_id, username=username)
if not provider_config: if not provider_config:
return None, None, None return None, None, None
coderai_config = provider_config.get('coderai_config') or {} coderai_config = provider_config.get('coderai_config') or {}
...@@ -91,8 +119,8 @@ def resolve_coderai_registration(provider_id: str) -> Tuple[Optional[int], Optio ...@@ -91,8 +119,8 @@ def resolve_coderai_registration(provider_id: str) -> Tuple[Optional[int], Optio
return owner_user_id, provider_config, registration_token return owner_user_id, provider_config, registration_token
def validate_coderai_registration_token(provider_id: str, presented_token: Optional[str]) -> Tuple[bool, Optional[int], Optional[Dict[str, Any]], Optional[str]]: def validate_coderai_registration_token(provider_id: str, presented_token: Optional[str], username: Optional[str] = None) -> Tuple[bool, Optional[int], Optional[Dict[str, Any]], Optional[str]]:
owner_user_id, provider_config, expected_token = resolve_coderai_registration(provider_id) owner_user_id, provider_config, expected_token = resolve_coderai_registration(provider_id, username=username)
if not provider_config: if not provider_config:
return False, None, None, 'Provider not found or not a coderai provider' return False, None, None, 'Provider not found or not a coderai provider'
if expected_token and presented_token != expected_token: if expected_token and presented_token != expected_token:
......
...@@ -23,6 +23,7 @@ Why did the programmer quit his job? Because he didn't get arrays! ...@@ -23,6 +23,7 @@ Why did the programmer quit his job? Because he didn't get arrays!
Request handlers for AISBF. Request handlers for AISBF.
""" """
import asyncio import asyncio
import base64
import re import re
import uuid import uuid
import hashlib import hashlib
...@@ -33,7 +34,7 @@ from concurrent.futures import ThreadPoolExecutor ...@@ -33,7 +34,7 @@ from concurrent.futures import ThreadPoolExecutor
from typing import Dict, List, Optional, Union from typing import Dict, List, Optional, Union
from pathlib import Path from pathlib import Path
from fastapi import HTTPException, Request from fastapi import HTTPException, Request
from fastapi.responses import JSONResponse, StreamingResponse from fastapi.responses import JSONResponse, StreamingResponse, Response
from .models import ChatCompletionRequest, ChatCompletionResponse from .models import ChatCompletionRequest, ChatCompletionResponse
from .providers import get_provider_handler, RateLimitError from .providers import get_provider_handler, RateLimitError
from .config import config from .config import config
...@@ -1744,6 +1745,31 @@ class RequestHandler: ...@@ -1744,6 +1745,31 @@ class RequestHandler:
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
body = self._adapt_studio_workflow_payload(provider_id, endpoint_path, body) body = self._adapt_studio_workflow_payload(provider_id, endpoint_path, body)
incoming_headers = {
key: value for key, value in request.headers.items()
if key.lower() not in {"host", "content-length", "connection"}
}
query_params = dict(request.query_params)
content_type = request.headers.get('content-type', '')
multipart_payload = None
if 'multipart/form-data' in content_type.lower():
form = await request.form()
fields = []
files = []
for key, value in form.multi_items():
filename = getattr(value, 'filename', None)
if filename is not None:
content = await value.read()
files.append({
'name': key,
'filename': filename,
'content_type': getattr(value, 'content_type', None),
'data_base64': base64.b64encode(content).decode('ascii'),
})
else:
fields.append({'name': key, 'value': str(value)})
multipart_payload = {'fields': fields, 'files': files}
# Support user-defined providers (dict format) and global providers (object format) # Support user-defined providers (dict format) and global providers (object format)
if self.user_id and provider_id in self.user_providers: if self.user_id and provider_id in self.user_providers:
...@@ -1773,13 +1799,50 @@ class RequestHandler: ...@@ -1773,13 +1799,50 @@ class RequestHandler:
logger.info(f"Generic proxy [{method}]: {provider_id} -> {url}") logger.info(f"Generic proxy [{method}]: {provider_id} -> {url}")
try: try:
provider_type = provider_config.get('type') if isinstance(provider_config, dict) else getattr(provider_config, 'type', None)
if provider_type == 'coderai':
provider_handler = get_provider_handler(provider_id, config_api_key, user_id=self.user_id)
wants_stream = 'text/event-stream' in request.headers.get('accept', '').lower() or endpoint_path in {"v1/audio/progress", "v1/video/progress", "v1/images/progress"}
status_code, payload = await provider_handler.proxy_native_request(
endpoint_path,
body,
method=method,
headers=incoming_headers,
query_params=query_params,
content_type=content_type,
multipart=multipart_payload,
stream=wants_stream,
)
response_headers = payload.get('headers') or {}
if payload.get('stream_chunks'):
media_type = payload.get('content_type') or 'text/event-stream'
async def iter_stream():
for chunk in payload.get('stream_chunks', []):
yield base64.b64decode(chunk)
return StreamingResponse(iter_stream(), status_code=status_code, media_type=media_type, headers=response_headers)
if payload.get('body_base64'):
media_type = payload.get('content_type') or 'application/octet-stream'
return Response(content=base64.b64decode(payload['body_base64']), status_code=status_code, media_type=media_type, headers=response_headers)
return JSONResponse(status_code=status_code, content=payload.get('body', payload), headers=response_headers)
async with httpx.AsyncClient(timeout=300) as client: async with httpx.AsyncClient(timeout=300) as client:
if method == "GET": if method == "GET":
resp = await client.get(url, headers=headers) resp = await client.get(url, headers=headers, params=query_params)
elif method == "DELETE": elif method == "DELETE":
resp = await client.delete(url, headers=headers) resp = await client.delete(url, headers=headers, params=query_params)
else: else:
resp = await client.post(url, json=body, headers=headers) if multipart_payload is not None:
files = []
data = []
for field in multipart_payload['fields']:
data.append((field['name'], field['value']))
for file_entry in multipart_payload['files']:
files.append((file_entry['name'], (file_entry['filename'], base64.b64decode(file_entry['data_base64']), file_entry.get('content_type') or 'application/octet-stream')))
resp = await client.post(url, data=data, files=files, headers={k: v for k, v in headers.items() if k.lower() != 'content-type'}, params=query_params)
else:
resp = await client.post(url, json=body, headers=headers, params=query_params)
try: try:
content = resp.json() content = resp.json()
except Exception: except Exception:
......
...@@ -28,6 +28,7 @@ import json ...@@ -28,6 +28,7 @@ import json
import logging import logging
import time import time
from typing import Any, AsyncIterator, Dict, List, Optional, Tuple, Union from typing import Any, AsyncIterator, Dict, List, Optional, Tuple, Union
import base64
from urllib.parse import urlparse from urllib.parse import urlparse
import httpx import httpx
...@@ -36,6 +37,7 @@ from openai import OpenAI ...@@ -36,6 +37,7 @@ from openai import OpenAI
from ..coderai_broker import broker as coderai_broker from ..coderai_broker import broker as coderai_broker
from ..config import config from ..config import config
from ..models import Model from ..models import Model
from ..app.templates import get_base_url
from .base import AISBF_DEBUG, BaseProviderHandler from .base import AISBF_DEBUG, BaseProviderHandler
...@@ -52,8 +54,10 @@ class CoderAIProviderHandler(BaseProviderHandler): ...@@ -52,8 +54,10 @@ class CoderAIProviderHandler(BaseProviderHandler):
self._coderai_config = self._resolve_coderai_config() self._coderai_config = self._resolve_coderai_config()
self._transport = str(self._coderai_config.get("transport") or self._infer_transport(self._raw_endpoint)).lower() self._transport = str(self._coderai_config.get("transport") or self._infer_transport(self._raw_endpoint)).lower()
self._client_id = self._coderai_config.get("client_id") or provider_id self._client_id = self._coderai_config.get("client_id") or provider_id
self._username = self._coderai_config.get("username") or ("global" if user_id is None else None)
self._bridge_path = str(self._coderai_config.get("bridge_path") or "/coderai/ws").strip() or "/coderai/ws" self._bridge_path = str(self._coderai_config.get("bridge_path") or "/coderai/ws").strip() or "/coderai/ws"
self._registration_path = str(self._coderai_config.get("registration_path") or "/coderai/register").strip() or "/coderai/register" self._registration_path = str(self._coderai_config.get("registration_path") or "/coderai/register").strip() or "/coderai/register"
self._broker_ws_path = str(self._coderai_config.get("broker_ws_path") or "/api/coderai/wss").strip() or "/api/coderai/wss"
self._request_timeout = float(self._coderai_config.get("request_timeout") or 300.0) self._request_timeout = float(self._coderai_config.get("request_timeout") or 300.0)
self._model_timeout = float(self._coderai_config.get("model_timeout") or 30.0) self._model_timeout = float(self._coderai_config.get("model_timeout") or 30.0)
self._websocket_enabled = bool(self._coderai_config.get("websocket_enabled", True)) self._websocket_enabled = bool(self._coderai_config.get("websocket_enabled", True))
...@@ -159,6 +163,8 @@ class CoderAIProviderHandler(BaseProviderHandler): ...@@ -159,6 +163,8 @@ class CoderAIProviderHandler(BaseProviderHandler):
"x-coderai-client-id": self._client_id, "x-coderai-client-id": self._client_id,
"x-coderai-provider-id": self.provider_id, "x-coderai-provider-id": self.provider_id,
} }
if self._username:
headers["x-coderai-username"] = self._username
token = self._bridge_token or self._registration_token or self.api_key token = self._bridge_token or self._registration_token or self.api_key
if token: if token:
headers["authorization"] = f"Bearer {token}" headers["authorization"] = f"Bearer {token}"
...@@ -171,6 +177,8 @@ class CoderAIProviderHandler(BaseProviderHandler): ...@@ -171,6 +177,8 @@ class CoderAIProviderHandler(BaseProviderHandler):
"X-CoderAI-Client-Id": self._client_id, "X-CoderAI-Client-Id": self._client_id,
"X-CoderAI-Provider-Id": self.provider_id, "X-CoderAI-Provider-Id": self.provider_id,
} }
if self._username:
headers["X-CoderAI-Username"] = self._username
token = self._bridge_token or self._registration_token or self.api_key token = self._bridge_token or self._registration_token or self.api_key
if token: if token:
headers["Authorization"] = f"Bearer {token}" headers["Authorization"] = f"Bearer {token}"
...@@ -211,21 +219,49 @@ class CoderAIProviderHandler(BaseProviderHandler): ...@@ -211,21 +219,49 @@ class CoderAIProviderHandler(BaseProviderHandler):
status = message.get("status") or "ok" status = message.get("status") or "ok"
if status == "error": if status == "error":
raise Exception(message.get("error") or "CoderAI broker bridge error") raise Exception(message.get("error") or "CoderAI broker bridge error")
payload_data = message.get("payload") or {} async for chunk in self._iter_broker_stream_chunks(message, timeout):
chunk = payload_data.get("chunk") yield chunk
if message.get("event") == "chunk" and chunk is not None:
if isinstance(chunk, str): @staticmethod
yield chunk.encode("utf-8") def _decode_broker_chunk(chunk: Any) -> bytes:
elif isinstance(chunk, bytes): if isinstance(chunk, bytes):
yield chunk return chunk
elif isinstance(chunk, dict): if isinstance(chunk, str):
yield f"data: {json.dumps(chunk)}\n\n".encode("utf-8") return chunk.encode("utf-8")
return if isinstance(chunk, dict):
for item in payload_data.get("chunks", []): if isinstance(chunk.get("data_base64"), str):
if isinstance(item, str): return base64.b64decode(chunk["data_base64"])
yield item.encode("utf-8") if chunk.get("encoding") == "base64" and isinstance(chunk.get("data"), str):
elif isinstance(item, dict): return base64.b64decode(chunk["data"])
yield f"data: {json.dumps(item)}\n\n".encode("utf-8") if "chunk" in chunk:
return CoderAIProviderHandler._decode_broker_chunk(chunk.get("chunk"))
return f"data: {json.dumps(chunk)}\n\n".encode("utf-8")
return str(chunk).encode("utf-8")
async def _iter_broker_stream_chunks(self, initial_message: Dict[str, Any], timeout: float) -> AsyncIterator[bytes]:
message = initial_message
while True:
status = message.get("status") or "ok"
if status == "error":
raise Exception(message.get("error") or "CoderAI broker bridge error")
event = message.get("event")
payload_data = message.get("payload") or {}
if event in {"chunk", "progress", "output", "log", "data"}:
chunk = payload_data.get("chunk", payload_data)
yield self._decode_broker_chunk(chunk)
elif isinstance(payload_data.get("chunks"), list):
for item in payload_data.get("chunks", []):
yield self._decode_broker_chunk(item)
if event in {None, "done", "completed"}:
return
next_request_id = message.get("request_id")
if not next_request_id:
return
message = await coderai_broker.wait_for_stream_event(next_request_id, timeout=timeout)
def validate_credentials(self) -> bool: def validate_credentials(self) -> bool:
if self._transport == "websocket" and not self._websocket_enabled: if self._transport == "websocket" and not self._websocket_enabled:
...@@ -478,23 +514,46 @@ class CoderAIProviderHandler(BaseProviderHandler): ...@@ -478,23 +514,46 @@ class CoderAIProviderHandler(BaseProviderHandler):
return message.get("payload") or {} return message.get("payload") or {}
return await self._http_json("POST", self._registration_path, payload, timeout=self._model_timeout) return await self._http_json("POST", self._registration_path, payload, timeout=self._model_timeout)
async def proxy_native_request(self, endpoint_path: str, body: Optional[Dict[str, Any]] = None, method: str = "POST") -> Tuple[int, Dict[str, Any]]: async def proxy_native_request(
self,
endpoint_path: str,
body: Optional[Dict[str, Any]] = None,
method: str = "POST",
headers: Optional[Dict[str, str]] = None,
query_params: Optional[Dict[str, Any]] = None,
content_type: Optional[str] = None,
multipart: Optional[Dict[str, Any]] = None,
stream: bool = False,
) -> Tuple[int, Dict[str, Any]]:
payload = { payload = {
"endpoint_path": endpoint_path, "endpoint_path": endpoint_path,
"method": method.upper(), "method": method.upper(),
"body": body or {}, "body": body or {},
"headers": headers or {},
"query_params": query_params or {},
} }
if content_type:
payload["content_type"] = content_type
if multipart is not None:
payload["multipart"] = multipart
if stream:
payload["stream"] = True
if await self._use_broker(): if await self._use_broker():
if stream:
chunks = []
async for chunk in self._broker_stream("proxy", payload, timeout=self._request_timeout):
chunks.append(chunk)
return 200, {"stream_chunks": [base64.b64encode(chunk).decode("ascii") for chunk in chunks], "stream_encoding": "base64"}
message = await self._broker_request("proxy", payload, timeout=self._request_timeout) message = await self._broker_request("proxy", payload, timeout=self._request_timeout)
if (message.get("status") or "ok") == "error": if (message.get("status") or "ok") == "error":
raise Exception(message.get("error") or "CoderAI broker proxy request failed") raise Exception(message.get("error") or "CoderAI broker proxy request failed")
envelope = message.get("payload") or {} envelope = message.get("payload") or {}
return int(envelope.get("status_code") or 200), envelope.get("body") or {} return int(envelope.get("status_code") or 200), envelope
if self._transport == "websocket": if self._transport == "websocket":
message = await self._ws_roundtrip("proxy", payload, timeout=self._request_timeout) message = await self._ws_roundtrip("proxy", payload, timeout=self._request_timeout)
if (message.get("status") or "ok") == "error": if (message.get("status") or "ok") == "error":
raise Exception(message.get("error") or "CoderAI proxy request failed") raise Exception(message.get("error") or "CoderAI proxy request failed")
envelope = message.get("payload") or {} envelope = message.get("payload") or {}
return int(envelope.get("status_code") or 200), envelope.get("body") or {} return int(envelope.get("status_code") or 200), envelope
response = await self._http_json(method.upper(), endpoint_path, body or {}, timeout=self._request_timeout) response = await self._http_json(method.upper(), endpoint_path, body or {}, timeout=self._request_timeout)
return 200, response return 200, response
...@@ -461,6 +461,18 @@ async def _generic_proxy(request: Request, body: dict, endpoint_path: str, metho ...@@ -461,6 +461,18 @@ async def _generic_proxy(request: Request, body: dict, endpoint_path: str, metho
return await handler.handle_generic_proxy(request, provider_id, endpoint_path, body, method=method) return await handler.handle_generic_proxy(request, provider_id, endpoint_path, body, method=method)
async def _generic_progress_proxy(request: Request, endpoint_path: str) -> JSONResponse:
user_id = getattr(request.state, 'user_id', None)
handler = _get_user_handler('request', user_id)
provider = request.query_params.get('provider', '').strip()
model = request.query_params.get('model', '').strip()
if not provider and model:
provider, _ = _resolve_provider(model, user_id=user_id, handler=handler)
if provider:
return await handler.handle_generic_proxy(request, provider, endpoint_path, {}, method="GET")
return {"active": False, "current": 0, "total": 0, "pct": 0, "elapsed": 0}
# ── Images ──────────────────────────────────────────────────────────────────── # ── Images ────────────────────────────────────────────────────────────────────
@router.post("/api/v1/images/edits") @router.post("/api/v1/images/edits")
...@@ -649,20 +661,20 @@ def _studio_scope(request: Request) -> tuple[str, Optional[int]]: ...@@ -649,20 +661,20 @@ def _studio_scope(request: Request) -> tuple[str, Optional[int]]:
@router.get("/v1/audio/progress") @router.get("/v1/audio/progress")
@router.get("/api/v1/audio/progress") @router.get("/api/v1/audio/progress")
async def studio_audio_progress(): async def studio_audio_progress(request: Request):
return {"active": False, "current": 0, "total": 0, "pct": 0, "elapsed": 0} return await _generic_progress_proxy(request, "v1/audio/progress")
@router.get("/v1/video/progress") @router.get("/v1/video/progress")
@router.get("/api/v1/video/progress") @router.get("/api/v1/video/progress")
async def studio_video_progress(): async def studio_video_progress(request: Request):
return {"active": False, "current": 0, "total": 0, "pct": 0, "elapsed": 0} return await _generic_progress_proxy(request, "v1/video/progress")
@router.get("/v1/images/progress") @router.get("/v1/images/progress")
@router.get("/api/v1/images/progress") @router.get("/api/v1/images/progress")
async def studio_images_progress(): async def studio_images_progress(request: Request):
return {"active": False, "current": 0, "total": 0, "pct": 0, "elapsed": 0} return await _generic_progress_proxy(request, "v1/images/progress")
@router.get("/v1/archive") @router.get("/v1/archive")
......
...@@ -3,6 +3,7 @@ from __future__ import annotations ...@@ -3,6 +3,7 @@ from __future__ import annotations
import json import json
import logging import logging
import time import time
import asyncio
from typing import Optional from typing import Optional
from fastapi import APIRouter, HTTPException, Query, WebSocket, WebSocketDisconnect from fastapi import APIRouter, HTTPException, Query, WebSocket, WebSocketDisconnect
...@@ -16,17 +17,18 @@ router = APIRouter() ...@@ -16,17 +17,18 @@ router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@router.websocket("/api/coderai/broker/ws") async def _coderai_broker_websocket_impl(websocket: WebSocket, scope_name: str):
async def coderai_broker_websocket(websocket: WebSocket):
provider_id = websocket.query_params.get("provider_id") or websocket.headers.get("x-coderai-provider-id") or "coderai" provider_id = websocket.query_params.get("provider_id") or websocket.headers.get("x-coderai-provider-id") or "coderai"
client_id = websocket.query_params.get("client_id") or websocket.headers.get("x-coderai-client-id") or f"anon-{int(time.time())}" client_id = websocket.query_params.get("client_id") or websocket.headers.get("x-coderai-client-id") or f"anon-{int(time.time())}"
username = websocket.query_params.get("username") or websocket.headers.get("x-coderai-username") or scope_name
presented_token = websocket.query_params.get("registration_token") or websocket.headers.get("x-coderai-registration-token") presented_token = websocket.query_params.get("registration_token") or websocket.headers.get("x-coderai-registration-token")
valid, owner_user_id, provider_config, error = validate_coderai_registration_token(provider_id, presented_token) valid, owner_user_id, provider_config, error = validate_coderai_registration_token(provider_id, presented_token, username=username)
if not valid: if not valid:
await websocket.close(code=1008, reason=error or "registration rejected") await websocket.close(code=1008, reason=error or "registration rejected")
return return
await websocket.accept() await websocket.accept()
session = await broker.register(websocket, provider_id, client_id, metadata={"source": "websocket", "owner_user_id": owner_user_id}) expected_scope = scope_name
session = await broker.register(websocket, provider_id, client_id, metadata={"source": "websocket", "owner_user_id": owner_user_id, "username": username, "scope_name": expected_scope, "proxy_scheme": websocket.url.scheme})
try: try:
await websocket.send_text(json.dumps({ await websocket.send_text(json.dumps({
...@@ -35,59 +37,71 @@ async def coderai_broker_websocket(websocket: WebSocket): ...@@ -35,59 +37,71 @@ async def coderai_broker_websocket(websocket: WebSocket):
"session_id": session.session_id, "session_id": session.session_id,
"provider_id": session.provider_id, "provider_id": session.provider_id,
"client_id": session.client_id, "client_id": session.client_id,
"username": username,
"scope_name": expected_scope,
"accepted": True, "accepted": True,
})) }))
while True: while True:
raw = await websocket.receive_text() try:
message = json.loads(raw) raw = await asyncio.wait_for(websocket.receive_text(), timeout=1.0)
op = message.get("op") message = json.loads(raw)
if op == "register": op = message.get("op")
payload = message.get("payload") or {} if op == "register":
payload_token = payload.get("registration_token") or message.get("registration_token") payload = message.get("payload") or {}
if payload_token and payload_token != presented_token: payload_token = payload.get("registration_token") or message.get("registration_token")
if payload_token and payload_token != presented_token:
await websocket.send_text(json.dumps({
"v": 1,
"request_id": message.get("request_id"),
"status": "error",
"error": "Registration token mismatch",
}))
continue
capabilities = payload.get("capabilities") or message.get("capabilities") or {}
metadata = {
"endpoint": payload.get("endpoint"),
"transport": payload.get("transport"),
"studio_endpoints": payload.get("studio_endpoints") or [],
"owner_user_id": owner_user_id,
"username": username,
"scope_name": expected_scope,
"proxy_scheme": websocket.url.scheme,
}
await broker.touch(session.session_id, metadata=metadata, capabilities=capabilities)
await websocket.send_text(json.dumps({ await websocket.send_text(json.dumps({
"v": 1, "v": 1,
"request_id": message.get("request_id"), "request_id": message.get("request_id"),
"status": "error", "status": "ok",
"error": "Registration token mismatch", "payload": {
"accepted": True,
"session_id": session.session_id,
"provider_id": session.provider_id,
"client_id": session.client_id,
"owner_user_id": owner_user_id,
"username": username,
"scope_name": expected_scope,
"expires_at": int(time.time()) + 86400,
},
})) }))
continue continue
capabilities = payload.get("capabilities") or message.get("capabilities") or {} if op == "heartbeat":
metadata = { await broker.touch(session.session_id, metadata=message.get("payload") or {})
"endpoint": payload.get("endpoint"), await websocket.send_text(json.dumps({
"transport": payload.get("transport"), "v": 1,
"studio_endpoints": payload.get("studio_endpoints") or [], "request_id": message.get("request_id"),
"owner_user_id": owner_user_id, "status": "ok",
} "event": "heartbeat",
await broker.touch(session.session_id, metadata=metadata, capabilities=capabilities) "payload": {"ts": int(time.time())},
await websocket.send_text(json.dumps({ }))
"v": 1, continue
"request_id": message.get("request_id"), await broker.touch(session.session_id)
"status": "ok", await broker.publish_response(message)
"payload": { except asyncio.TimeoutError:
"accepted": True, queued = await broker.consume_request(session.session_id, timeout=1)
"session_id": session.session_id, if queued is not None:
"provider_id": session.provider_id, await websocket.send_text(json.dumps(queued))
"client_id": session.client_id, await broker.touch(session.session_id, metadata={"proxy_scheme": websocket.url.scheme, "username": username, "scope_name": expected_scope})
"owner_user_id": owner_user_id,
"expires_at": int(time.time()) + 86400,
},
}))
continue
if op == "heartbeat":
await broker.touch(session.session_id, metadata=message.get("payload") or {})
await websocket.send_text(json.dumps({
"v": 1,
"request_id": message.get("request_id"),
"status": "ok",
"event": "heartbeat",
"payload": {"ts": int(time.time())},
}))
continue continue
await broker.touch(session.session_id)
resolved = await broker.resolve_response(message)
if not resolved:
logger.debug(f"CoderAI broker received unmatched message from provider={provider_id} client={client_id}: {message.get('request_id')}")
except WebSocketDisconnect: except WebSocketDisconnect:
logger.info(f"CoderAI broker disconnected provider={provider_id} client={client_id}") logger.info(f"CoderAI broker disconnected provider={provider_id} client={client_id}")
except Exception as e: except Exception as e:
...@@ -97,6 +111,16 @@ async def coderai_broker_websocket(websocket: WebSocket): ...@@ -97,6 +111,16 @@ async def coderai_broker_websocket(websocket: WebSocket):
await broker.fail_session_requests(session.session_id, f"CoderAI session '{session.session_id}' disconnected") await broker.fail_session_requests(session.session_id, f"CoderAI session '{session.session_id}' disconnected")
@router.websocket("/api/coderai/wss")
async def coderai_broker_websocket_global(websocket: WebSocket):
await _coderai_broker_websocket_impl(websocket, "global")
@router.websocket("/api/u/{username}/coderai/wss")
async def coderai_broker_websocket_user(websocket: WebSocket, username: str):
await _coderai_broker_websocket_impl(websocket, username)
@router.get("/api/coderai/broker/sessions") @router.get("/api/coderai/broker/sessions")
async def coderai_broker_sessions(): async def coderai_broker_sessions():
return {"sessions": await broker.list_sessions()} return {"sessions": await broker.list_sessions()}
......
...@@ -469,6 +469,18 @@ async def _user_delete_proxy(request: Request, username: str, provider: str, end ...@@ -469,6 +469,18 @@ async def _user_delete_proxy(request: Request, username: str, provider: str, end
return await handler.handle_generic_proxy(request, provider, endpoint_path, {}, method="DELETE") return await handler.handle_generic_proxy(request, provider, endpoint_path, {}, method="DELETE")
async def _user_progress_proxy(request: Request, username: str, endpoint_path: str) -> JSONResponse:
user_id = _check_user_access(request, username)
handler = _get_user_handler('request', user_id)
provider = request.query_params.get('provider', '').strip()
model = request.query_params.get('model', '').strip()
if not provider and model:
provider, _ = parse_provider_from_model(model)
if provider:
return await handler.handle_generic_proxy(request, provider, endpoint_path, {}, method="GET")
return {"active": False, "current": 0, "total": 0, "pct": 0, "elapsed": 0}
# ── Audio ───────────────────────────────────────────────────────────────────── # ── Audio ─────────────────────────────────────────────────────────────────────
@router.post("/api/u/{username}/audio/transcriptions") @router.post("/api/u/{username}/audio/transcriptions")
...@@ -511,6 +523,11 @@ async def user_audio_clone(request: Request, username: str, body: dict): ...@@ -511,6 +523,11 @@ async def user_audio_clone(request: Request, username: str, body: dict):
async def user_audio_convert(request: Request, username: str, body: dict): async def user_audio_convert(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/audio/convert") return await _user_generic_proxy(request, username, body, "v1/audio/convert")
@router.get("/api/u/{username}/audio/progress")
async def user_audio_progress(request: Request, username: str):
return await _user_progress_proxy(request, username, "v1/audio/progress")
@router.post("/api/u/{username}/audio/identify") @router.post("/api/u/{username}/audio/identify")
async def user_audio_identify(request: Request, username: str, body: dict): async def user_audio_identify(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/audio/identify") return await _user_generic_proxy(request, username, body, "v1/audio/identify")
...@@ -615,6 +632,11 @@ async def user_image_from3d(request: Request, username: str, body: dict): ...@@ -615,6 +632,11 @@ async def user_image_from3d(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/images/from3d") return await _user_generic_proxy(request, username, body, "v1/images/from3d")
@router.get("/api/u/{username}/images/progress")
async def user_images_progress(request: Request, username: str):
return await _user_progress_proxy(request, username, "v1/images/progress")
# ── Video ───────────────────────────────────────────────────────────────────── # ── Video ─────────────────────────────────────────────────────────────────────
@router.post("/api/u/{username}/video/generations") @router.post("/api/u/{username}/video/generations")
...@@ -662,6 +684,11 @@ async def user_video_from3d(request: Request, username: str, body: dict): ...@@ -662,6 +684,11 @@ async def user_video_from3d(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/video/from3d") return await _user_generic_proxy(request, username, body, "v1/video/from3d")
@router.get("/api/u/{username}/video/progress")
async def user_video_progress(request: Request, username: str):
return await _user_progress_proxy(request, username, "v1/video/progress")
# ── Embeddings ──────────────────────────────────────────────────────────────── # ── Embeddings ────────────────────────────────────────────────────────────────
@router.post("/api/u/{username}/embeddings") @router.post("/api/u/{username}/embeddings")
......
# CoderAI Broker Implementation Reference
## Purpose
This document is the single source of truth for implementing the CoderAI side of the AISBF broker and bridge integration.
The target audience is another LLM or engineer implementing CoderAI, not AISBF.
The implementation must let a CoderAI instance:
- expose direct HTTP and optional direct WebSocket bridge endpoints for AISBF
- connect outward to AISBF over WebSocket when CoderAI is behind NAT
- register against either a global or user-owned AISBF `coderai` provider
- receive brokered requests from AISBF
- execute those requests locally inside CoderAI
- send responses back to AISBF using the envelope protocol described here
This reference supersedes separate fragmented notes. Treat this file as the canonical contract.
## High-Level Goal
Implement a persistent outbound broker client in CoderAI plus the local handlers needed to serve AISBF requests.
The broker client should:
1. Dial AISBF over `ws://` or `wss://`
2. Authenticate using the provider-scoped `registration_token`
3. Register metadata and capabilities after connect
4. Stay connected with heartbeat support
5. Receive queued or direct broker requests from AISBF
6. Execute supported operations locally
7. Send back success, error, binary, and streaming envelopes with the same `request_id`
8. Automatically reconnect if the connection drops
## AISBF Concepts You Must Match
AISBF supports `coderai` as a first-class provider.
Each `coderai` provider belongs to exactly one owner:
- global admin scope
- user scope
The broker session must register into the correct scope.
### Scope Rules
- Global provider connections use the global broker path and `username=global`
- User-owned provider connections use the user-scoped broker path and `username=<aisbf_username>`
- The registration token belongs to that exact provider configuration and owner scope
- One broker session must not be reused across unrelated owners
- AISBF rejects broker request use if the owner principal does not match the connected session owner
## Broker Endpoints
### Global Scope
```text
wss://<aisbf-host>/api/coderai/wss?provider_id=<provider_id>&client_id=<client_id>&username=global&registration_token=<token>
```
### User Scope
```text
wss://<aisbf-host>/api/u/<username>/coderai/wss?provider_id=<provider_id>&client_id=<client_id>&username=<username>&registration_token=<token>
```
### Notes
- Use `wss://` whenever AISBF is exposed through HTTPS or TLS termination
- Use the externally visible URL, not necessarily AISBF's internal bind address
- AISBF may sit behind a reverse proxy that terminates TLS
- The CoderAI client must work with both direct TLS and reverse-proxy-managed TLS
## Required Connection Parameters
The outbound WebSocket connection must include:
- `provider_id`
- `client_id`
- `username`
- `registration_token`
### Meaning
- `provider_id`: AISBF provider id such as `coderai` or `my-coderai`
- `client_id`: stable machine or session identifier chosen in provider config, such as `workstation-01`
- `username`: either `global` or the AISBF username for user-owned providers
- `registration_token`: provider-scoped secret from AISBF provider configuration
## Optional Headers
AISBF also accepts or may expect these headers:
- `Authorization: Bearer <registration_token or bridge token>`
- `x-coderai-provider-id: <provider_id>`
- `x-coderai-client-id: <client_id>`
- `x-coderai-username: <username>`
Recommended behavior:
- include both query params and headers for robustness
- use the registration token as bearer auth if no separate bridge token exists
## Broker Session Lifecycle
### 1. Connect
Open the outbound WebSocket to the correct scoped AISBF endpoint.
### 2. Wait for `registered` event
AISBF immediately sends a registration acknowledgment event on successful admission.
Example:
```json
{
"v": 1,
"event": "registered",
"session_id": "coderai_abc123",
"provider_id": "coderai",
"client_id": "workstation-01",
"username": "global",
"scope_name": "global",
"accepted": true
}
```
Store:
- `session_id`
- `provider_id`
- `client_id`
- `username`
- `scope_name`
### 3. Send explicit `register` operation
After the `registered` event, CoderAI must send a `register` message describing its capabilities and advertised endpoints.
### 4. Enter long-lived receive loop
Then keep listening for incoming broker requests from AISBF.
### 5. Heartbeat and reconnect
If the socket drops:
- reconnect with backoff
- re-register after reconnect
- preserve the same stable `client_id`
## Required `register` Message
CoderAI should send this after receiving the initial AISBF `registered` event.
```json
{
"v": 1,
"op": "register",
"request_id": "reg-1",
"payload": {
"endpoint": "ws://local-coderai-or-descriptive-endpoint",
"transport": "websocket",
"registration_token": "<same_registration_token>",
"studio_endpoints": [
"v1/images/generate",
"v1/audio/tts",
"v1/audio/transcriptions",
"v1/audio/progress",
"v1/video/dub",
"v1/video/progress"
],
"capabilities": {
"studio": {
"enabled": true,
"endpoints": [
"v1/images/generate",
"v1/images/progress",
"v1/audio/tts",
"v1/audio/progress",
"v1/video/dub",
"v1/video/progress"
],
"endpoint_capabilities": {
"v1/video/dub": {
"methods": ["POST"],
"input_modalities": ["text", "video", "audio"],
"output_modalities": ["video"],
"supports_stream": true,
"supports_multipart": true,
"supports_binary": true
},
"v1/video/progress": {
"methods": ["GET"],
"input_modalities": [],
"output_modalities": ["progress"],
"supports_stream": true,
"supports_binary": false
}
}
},
"openai_compat": {
"chat_completions": true,
"models": true,
"embeddings": true,
"images": true,
"audio": true
}
}
}
}
```
AISBF replies with a success envelope.
## Required Heartbeat Support
AISBF may send heartbeat requests, and CoderAI may also proactively keep the socket alive.
### If AISBF sends heartbeat
Request example:
```json
{
"v": 1,
"op": "heartbeat",
"request_id": "hb-123",
"payload": {}
}
```
Reply example:
```json
{
"v": 1,
"request_id": "hb-123",
"status": "ok",
"event": "heartbeat",
"payload": {
"ts": 1746960000
}
}
```
### Optional proactive heartbeat
CoderAI may also periodically send:
```json
{
"v": 1,
"op": "heartbeat",
"request_id": "hb-self-1",
"payload": {
"uptime": 1234
}
}
```
## Local HTTP Endpoints CoderAI Should Expose
### OpenAI-compatible endpoints
At minimum:
- `GET /v1/models`
- `POST /v1/chat/completions`
Optional additional OpenAI-compatible endpoints may also be exposed if AISBF will use them.
Preferred `/v1/models` response:
```json
{
"data": [
{
"id": "llama3.1:8b",
"name": "llama3.1:8b",
"description": "Local general-purpose chat model",
"context_length": 131072,
"architecture": {
"input_modalities": ["text"],
"output_modalities": ["text"]
},
"supported_parameters": ["temperature", "top_p", "max_tokens"],
"default_parameters": {
"temperature": 0.7
},
"pricing": null,
"studio_capabilities": ["chat", "tool_use", "code_generation"]
}
]
}
```
### Capabilities endpoint
Expose:
- `GET /coderai/capabilities`
Recommended response:
```json
{
"server": {
"name": "coderai",
"version": "0.1.0"
},
"transports": {
"http": true,
"websocket": true
},
"openai_compat": {
"chat_completions": true,
"models": true,
"responses": false,
"embeddings": true,
"images": true,
"audio": true
},
"studio": {
"enabled": true,
"endpoints": [
"v1/images/generate",
"v1/images/progress",
"v1/audio/tts",
"v1/audio/transcriptions",
"v1/audio/progress",
"v1/video/dub",
"v1/video/progress"
],
"endpoint_capabilities": {
"v1/images/generate": {
"methods": ["POST"],
"input_modalities": ["text", "image"],
"output_modalities": ["image"],
"supports_stream": false,
"supports_multipart": true,
"supports_binary": true
},
"v1/images/progress": {
"methods": ["GET"],
"input_modalities": [],
"output_modalities": ["progress"],
"supports_stream": true,
"supports_binary": false
}
}
},
"models": [
{
"id": "llama3.1:8b",
"studio_capabilities": ["chat", "tool_use", "code_generation"]
}
]
}
```
## Direct WebSocket Bridge
### Path
CoderAI should accept WebSocket clients on:
- `/coderai/ws`
or another configured path mirrored in `coderai_config.bridge_path`.
### Headers AISBF sends
- `Authorization: Bearer <bridge_token_or_registration_token_or_api_key>` if available
- `x-coderai-client-id: <client_id>`
- `x-coderai-provider-id: <provider_id>`
- optionally `x-coderai-username: <username>`
## Incoming Request Envelope Format
AISBF sends one JSON envelope per operation.
Example:
```json
{
"v": 1,
"op": "chat.completions",
"request_id": "coderai-1746960000000",
"provider_id": "coderai",
"client_id": "aisbf-default",
"registration_token": "optional-shared-secret",
"payload": {
"model": "llama3.1:8b",
"messages": [
{"role": "user", "content": "hello"}
],
"stream": false
}
}
```
## Supported Operations
CoderAI must implement these operations:
- `models.list`
- `chat.completions`
- `capabilities`
- `register`
- `proxy`
- `heartbeat`
### `op = "models.list"`
Request payload:
```json
{}
```
Response payload should match `GET /v1/models`.
### `op = "chat.completions"`
Request payload matches OpenAI `POST /v1/chat/completions` body.
### `op = "capabilities"`
Response payload should match `GET /coderai/capabilities`.
### `op = "register"`
Used for outbound-only broker registration and metadata refresh.
### `op = "proxy"`
Used to tunnel arbitrary Studio-native and compatible endpoints over broker or direct WebSocket transport.
Request payload may include:
```json
{
"endpoint_path": "v1/video/dub",
"method": "POST",
"headers": {
"x-request-id": "studio-job-123",
"accept": "text/event-stream"
},
"query_params": {
"job_id": "dub_123"
},
"body": {
"model": "local-video-model",
"input": "Dub this clip to Italian"
},
"multipart": {
"fields": [
{"name": "model", "value": "whisper-large"}
],
"files": [
{
"name": "file",
"filename": "sample.wav",
"content_type": "audio/wav",
"data_base64": "<base64>"
}
]
},
"content_type": "multipart/form-data",
"stream": true
}
```
Semantics:
- `headers`: forward arbitrary request headers when safe
- `query_params`: forward arbitrary query string values
- `body`: JSON body for non-multipart requests
- `multipart.fields`: repeated form fields
- `multipart.files`: uploaded files encoded in base64 with metadata
- `content_type`: original inbound content type if relevant
- `stream: true`: caller expects incremental response events instead of only a one-shot JSON body
## Response Envelope Types
### Non-streaming success
```json
{
"v": 1,
"request_id": "coderai-1746960000000",
"status": "ok",
"payload": {
"id": "chatcmpl-123",
"object": "chat.completion",
"created": 1746960000,
"model": "llama3.1:8b",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "hello"},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 5,
"total_tokens": 15
}
}
}
```
### Error
```json
{
"v": 1,
"request_id": "coderai-1746960000000",
"status": "error",
"error": "Model not available",
"code": "model_not_found",
"details": {
"model": "missing-model"
}
}
```
### Proxy success with JSON body
```json
{
"v": 1,
"request_id": "coderai-1746960000000",
"status": "ok",
"payload": {
"status_code": 200,
"headers": {
"content-type": "application/json"
},
"body": {
"job_id": "dub_123",
"status": "queued"
}
}
}
```
### Proxy success with binary body
```json
{
"v": 1,
"request_id": "coderai-1746960000000",
"status": "ok",
"payload": {
"status_code": 200,
"content_type": "audio/mpeg",
"headers": {
"content-disposition": "attachment; filename=preview.mp3"
},
"body_base64": "<base64>"
}
}
```
## Streaming Event Protocol
For long-running audio, image, video, or pipeline jobs, send multiple envelopes with the same `request_id`.
Supported event types include:
- `chunk`
- `progress`
- `output`
- `log`
- `data`
- final `done`
- final `completed`
### Chat streaming chunk example
```json
{
"v": 1,
"request_id": "coderai-1746960000000",
"status": "ok",
"event": "chunk",
"payload": {
"chunk": "data: {\"id\":\"chatcmpl-123\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"delta\":{\"content\":\"hel\"},\"index\":0,\"finish_reason\":null}]}\n\n"
}
}
```
### Progress event example
```json
{
"v": 1,
"request_id": "coderai-1746960000000",
"status": "ok",
"event": "progress",
"payload": {
"chunk": "event: progress\ndata: {\"active\":true,\"current\":5,\"total\":20,\"pct\":25,\"elapsed\":12}\n\n"
}
}
```
### Binary stream chunk example
```json
{
"v": 1,
"request_id": "coderai-1746960000000",
"status": "ok",
"event": "output",
"payload": {
"chunk": {
"data_base64": "<base64>"
}
}
}
```
### Final event example
```json
{
"v": 1,
"request_id": "coderai-1746960000000",
"status": "ok",
"event": "done",
"payload": {}
}
```
Important rules:
- For SSE-style consumers, `payload.chunk` should usually already be a complete SSE fragment formatted exactly as AISBF should relay it
- Include `data: [DONE]\n\n` when upstream semantics require it
- For binary chunks, send `payload.chunk.data_base64`
- Keep all events on the same `request_id`
- End the stream with `done` or `completed`
## Progress Endpoints Used by Studio Dashboard
The AISBF Studio dashboard may proxy these progress endpoints through the broker or direct WebSocket path:
- `GET /v1/audio/progress`
- `GET /v1/video/progress`
- `GET /v1/images/progress`
You should support them when the corresponding long-running media jobs exist.
Recommended JSON shape when called over HTTP:
```json
{
"active": true,
"current": 5,
"total": 20,
"pct": 25,
"elapsed": 12,
"it_per_s": 1.4,
"unit": "steps"
}
```
Recommended streamed shape when called through broker streaming mode:
```json
{
"v": 1,
"request_id": "req-123",
"status": "ok",
"event": "progress",
"payload": {
"chunk": "event: progress\ndata: {\"active\":true,\"current\":5,\"total\":20,\"pct\":25,\"elapsed\":12,\"it_per_s\":1.4,\"unit\":\"steps\"}\n\n"
}
}
```
## Capability Advertisement Requirements
Advertise endpoint metadata clearly so AISBF can reason about custom pipelines.
For each custom endpoint, provide as many of these as possible:
- `methods`
- `input_modalities`
- `output_modalities`
- `supports_stream`
- `supports_multipart`
- `supports_binary`
- optional model restrictions
- optional job semantics such as `returns_job_id` and `requires_progress_polling`
For every model, provide:
- `id`
- `name`
- `description`
- `context_length`
- `architecture.input_modalities`
- `architecture.output_modalities`
- `supported_parameters`
- `default_parameters`
- `studio_capabilities`
For server capabilities, provide:
- transport availability
- OpenAI-compatible endpoint availability
- Studio-native endpoint availability
- endpoint capability metadata
- current server version
- optional hardware metadata such as `gpu`, `memory_gb`, `quantization`, `throughput_hint`
## Recommended CoderAI Architecture
1. OpenAI compatibility router
- exposes `/v1/models`, `/v1/chat/completions`, and any other supported OpenAI endpoints
2. Studio-native router
- exposes endpoints such as `v1/video/dub`, `v1/audio/tts`, `v1/images/generate`, progress endpoints, and other pipelines
3. Capabilities registry
- enumerates enabled endpoints and loaded models
- computes normalized `studio_capabilities`
- exposes endpoint capability metadata
4. WebSocket bridge server
- accepts AISBF envelopes
- dispatches by `op`
- handles `proxy` by internally calling the same handlers used by HTTP routes
- handles chat and non-chat streaming events
5. Optional outbound broker client
- maintains a persistent outbound WebSocket to AISBF-reachable broker endpoints
## Minimal Implementation Checklist
- add `/coderai/capabilities`
- add `/coderai/register` if you expose direct registration over HTTP
- add `/coderai/ws`
- expose model metadata with `studio_capabilities`
- support `models.list`
- support `chat.completions`
- support chat streaming `chunk` and `done`
- support `proxy` for Studio-native endpoints
- support arbitrary forwarded headers and query params in `proxy`
- support multipart uploads in `proxy`
- support base64 binary input and output in `proxy`
- support progress endpoints used by the AISBF Studio dashboard
- support non-chat streaming events for long-running media or pipeline jobs
- optionally support persistent outbound broker mode for NAT traversal
- protect bridge and register endpoints with a shared secret or signed token
## Compatibility Notes
- AISBF expects streamed chat chunks to already be formatted as SSE fragments when using chunk-style relay
- AISBF accepts binary stream chunks encoded with `data_base64`
- AISBF generic Studio proxy now uses the `coderai` bridge for non-chat endpoints, making NAT traversal possible for files, images, audio, video, and progress polling
- Owner isolation is enforced on the AISBF side, so correct scoped registration is mandatory
# CoderAI <-> AISBF Integration Contract # CoderAI Broker Implementation Reference
## Goal ## Purpose
Enable `coderai` to appear in AISBF as a first-class provider with two modes: This document is the single source of truth for implementing the CoderAI side of the AISBF broker and bridge integration.
1. **Direct local HTTP mode**: AISBF talks to a local OpenAI-compatible CoderAI server. The target audience is another LLM or engineer implementing CoderAI, not AISBF.
2. **NAT-friendly WebSocket bridge mode**: CoderAI connects outward and exchanges OpenAI-compatible requests/responses through framed WebSocket messages.
CoderAI is the upstream implementation source for Studio-native endpoints, so capability and endpoint discovery should be explicit and machine-readable. This document is mirrored in `docs/coderai-broker-implementation-reference.md` and should be kept identical in purpose and protocol coverage.
## AISBF broker mode ## AISBF broker mode
AISBF now includes a public broker-side WebSocket endpoint for outbound-only NAT traversal. AISBF now includes a public broker-side WebSocket endpoint for outbound-only NAT traversal.
- Broker WebSocket endpoint: `/api/coderai/broker/ws` - Broker WebSocket endpoint: `/api/coderai/broker/ws`
- Broker WebSocket endpoints:
- global scope: `/api/coderai/wss`
- user scope: `/api/u/{username}/coderai/wss`
- Broker session status endpoint: `/api/coderai/broker/providers/{provider_id}/status` - Broker session status endpoint: `/api/coderai/broker/providers/{provider_id}/status`
- Broker session listing endpoint: `/api/coderai/broker/sessions` - Broker session listing endpoint: `/api/coderai/broker/sessions`
...@@ -28,6 +30,13 @@ Registration tokens are resolved from the owning provider configuration. This me ...@@ -28,6 +30,13 @@ Registration tokens are resolved from the owning provider configuration. This me
- each user configures the token for their own user-scoped `coderai` providers - each user configures the token for their own user-scoped `coderai` providers
- a broker session is only usable by requests belonging to the same owner principal - a broker session is only usable by requests belonging to the same owner principal
Broker registration is now scope-aware:
- global providers register with `username=global`
- user-owned providers register with `username=<aisbf_username>`
- the same scoped path must be used by the CoderAI client when connecting over WebSocket
- deployments behind TLS termination or reverse proxies must connect with the externally visible `wss://...` URL and preserve proxy headers so AISBF can remain scheme-aware
The AISBF dashboard now exposes this token directly inside each `coderai` provider configuration: The AISBF dashboard now exposes this token directly inside each `coderai` provider configuration:
- token input is stored in `coderai_config.registration_token` - token input is stored in `coderai_config.registration_token`
...@@ -83,6 +92,7 @@ Use provider type: ...@@ -83,6 +92,7 @@ Use provider type:
- For `transport=http`, AISBF uses the OpenAI Python client against `endpoint + /v1`. - For `transport=http`, AISBF uses the OpenAI Python client against `endpoint + /v1`.
- For `transport=websocket`, AISBF uses a WebSocket bridge and sends framed JSON envelopes. - For `transport=websocket`, AISBF uses a WebSocket bridge and sends framed JSON envelopes.
- AISBF uses `models.list`, `chat.completions`, `capabilities`, `register`, and `proxy` bridge operations. - AISBF uses `models.list`, `chat.completions`, `capabilities`, `register`, and `proxy` bridge operations.
- `proxy` now supports arbitrary forwarded request headers, query params, multipart form payloads, binary/base64 bodies, progress polling endpoints, and non-chat streaming event envelopes for long-running jobs.
- AISBF treats `coderai` like an OpenAI-style Studio adapter family. - AISBF treats `coderai` like an OpenAI-style Studio adapter family.
- AISBF can also forward arbitrary Studio-native endpoints through `proxy` when the provider transport is WebSocket. - AISBF can also forward arbitrary Studio-native endpoints through `proxy` when the provider transport is WebSocket.
- AISBF validates that broker-enabled `coderai` providers have a non-empty `registration_token`. - AISBF validates that broker-enabled `coderai` providers have a non-empty `registration_token`.
...@@ -189,11 +199,18 @@ When CoderAI dials AISBF broker directly, it should connect using: ...@@ -189,11 +199,18 @@ When CoderAI dials AISBF broker directly, it should connect using:
- `provider_id=<provider_id>` - `provider_id=<provider_id>`
- `client_id=<client_id>` - `client_id=<client_id>`
- `username=<username-or-global>`
Example: Example:
```text ```text
wss://your-aisbf.example/api/coderai/broker/ws?provider_id=coderai&client_id=workstation-01&registration_token=<owner-configured-token> wss://your-aisbf.example/api/coderai/wss?provider_id=coderai&client_id=workstation-01&username=global&registration_token=<owner-configured-token>
```
User-scoped example:
```text
wss://your-aisbf.example/api/u/alice/coderai/wss?provider_id=my-coderai&client_id=workstation-01&username=alice&registration_token=<owner-configured-token>
``` ```
### Envelope format ### Envelope format
...@@ -297,7 +314,7 @@ Important: ...@@ -297,7 +314,7 @@ Important:
- This keeps AISBF transport-simple and lets CoderAI own protocol correctness. - This keeps AISBF transport-simple and lets CoderAI own protocol correctness.
- Include `data: [DONE]\n\n` as one of the streamed chunks when the upstream semantics require it. - Include `data: [DONE]\n\n` as one of the streamed chunks when the upstream semantics require it.
## Broker session visibility and persistence ## Broker session visibility, persistence, and multi-node routing
AISBF now tracks two broker states: AISBF now tracks two broker states:
...@@ -306,6 +323,15 @@ AISBF now tracks two broker states: ...@@ -306,6 +323,15 @@ AISBF now tracks two broker states:
Persisted metadata is dashboard-facing only. It is used to show the last known session details after restart, but it is not treated as an active transport path until CoderAI reconnects. Persisted metadata is dashboard-facing only. It is used to show the last known session details after restart, but it is not treated as an active transport path until CoderAI reconnects.
For multi-node AISBF deployments behind a reverse proxy / load balancer:
- session status and ownership metadata are stored in the configured AISBF cache backend
- requests are enqueued into cache-backed broker queues keyed by broker session id
- the AISBF node holding the live WebSocket consumes queued requests and forwards them to CoderAI
- replies are written back through cache-backed reply keys so the AISBF node that originated the request can receive the result
Redis is the preferred backend for this distributed mode. SQLite/MySQL can operate as polling-based fallbacks. Memory/file cache backends are not suitable for cross-node broker routing.
Expected behavior: Expected behavior:
- after reconnect, the persisted snapshot is refreshed with the new live session details - after reconnect, the persisted snapshot is refreshed with the new live session details
...@@ -393,10 +419,22 @@ Request payload: ...@@ -393,10 +419,22 @@ Request payload:
{ {
"endpoint_path": "v1/video/dub", "endpoint_path": "v1/video/dub",
"method": "POST", "method": "POST",
"headers": {
"x-request-id": "studio-job-123",
"accept": "text/event-stream"
},
"query_params": {
"job_id": "dub_123"
},
"body": { "body": {
"model": "local-video-model", "model": "local-video-model",
"input": "Dub this clip to Italian" "input": "Dub this clip to Italian"
} },
"multipart": {
"fields": [{"name": "model", "value": "whisper-large"}],
"files": [{"name": "file", "filename": "sample.wav", "content_type": "audio/wav", "data_base64": "<base64>"}]
},
"stream": true
} }
``` ```
...@@ -405,6 +443,9 @@ Response payload: ...@@ -405,6 +443,9 @@ Response payload:
```json ```json
{ {
"status_code": 200, "status_code": 200,
"headers": {
"content-type": "application/json"
},
"body": { "body": {
"job_id": "dub_123", "job_id": "dub_123",
"status": "queued" "status": "queued"
...@@ -412,6 +453,37 @@ Response payload: ...@@ -412,6 +453,37 @@ Response payload:
} }
``` ```
Binary response payloads may instead use:
```json
{
"status_code": 200,
"content_type": "audio/mpeg",
"body_base64": "<base64>",
"headers": {
"content-disposition": "attachment; filename=preview.mp3"
}
}
```
Streaming and progress responses may emit multiple envelopes with `event` values like `progress`, `output`, `log`, `data`, `chunk`, and finally `done` or `completed`.
Recommended progress chunk payload:
```json
{
"v": 1,
"request_id": "coderai-1746960000000",
"status": "ok",
"event": "progress",
"payload": {
"chunk": "event: progress\ndata: {\"active\":true,\"current\":5,\"total\":20,\"pct\":25,\"elapsed\":12}\n\n"
}
}
```
Capability advertisements should include endpoint metadata for custom pipelines, including supported methods, streaming mode, expected input/output modalities, and whether multipart or binary transport is required.
## Recommended CoderAI architecture ## Recommended CoderAI architecture
### Server components ### Server components
......
...@@ -48,8 +48,8 @@ class build_py(_build_py): ...@@ -48,8 +48,8 @@ class build_py(_build_py):
in site-packages/aisbf/_share/ and can be extracted by cli.py on first run. in site-packages/aisbf/_share/ and can be extracted by cli.py on first run.
""" """
_SHARE_FILES = ['main.py', 'requirements.txt', 'aisbf.sh', 'DOCUMENTATION.md', 'README.md', 'LICENSE.txt'] _SHARE_FILES = ['main.py', 'requirements.txt', 'aisbf.sh', 'DOCUMENTATION.md', 'README.md', 'LICENSE.txt', 'AI.PROMPT']
_SHARE_DIRS = ['templates', 'static', 'config', 'aisbf'] _SHARE_DIRS = ['templates', 'static', 'config', 'aisbf', 'docs']
def run(self): def run(self):
self._populate_share() self._populate_share()
...@@ -149,6 +149,7 @@ setup( ...@@ -149,6 +149,7 @@ setup(
'DOCUMENTATION.md', 'DOCUMENTATION.md',
'README.md', 'README.md',
'LICENSE.txt', 'LICENSE.txt',
'AI.PROMPT',
'config/providers.json', 'config/providers.json',
'config/rotations.json', 'config/rotations.json',
'config/autoselect.json', 'config/autoselect.json',
...@@ -158,6 +159,9 @@ setup( ...@@ -158,6 +159,9 @@ setup(
'config/STUDIO_SYSTEM.md', 'config/STUDIO_SYSTEM.md',
'config/aisbf.json', 'config/aisbf.json',
]), ]),
('share/aisbf/docs', [
'docs/coderai-integration.md',
]),
# Install aisbf package to share directory for venv installation # Install aisbf package to share directory for venv installation
# Main aisbf module files # Main aisbf module files
('share/aisbf/aisbf', [ ('share/aisbf/aisbf', [
......
import json import json
import base64
from unittest.mock import Mock from unittest.mock import Mock
import pytest import pytest
...@@ -150,3 +151,42 @@ async def test_coderai_websocket_stream_emits_sse_bytes(monkeypatch): ...@@ -150,3 +151,42 @@ async def test_coderai_websocket_stream_emits_sse_bytes(monkeypatch):
assert chunks[0].startswith(b"data: ") assert chunks[0].startswith(b"data: ")
assert chunks[-1] == b"data: [DONE]\n\n" assert chunks[-1] == b"data: [DONE]\n\n"
@pytest.mark.asyncio
async def test_coderai_broker_stream_supports_progress_and_binary_chunks(monkeypatch):
provider_config = {
"id": "coderai_nat",
"name": "CoderAI NAT",
"endpoint": "wss://broker.example.test/coderai/ws",
"type": "coderai",
"api_key_required": False,
"coderai_config": {"transport": "websocket", "bridge_path": "/coderai/ws"},
}
handler = CoderAIProviderHandler("coderai_nat", provider_config=provider_config)
async def fake_broker_request(op, payload, timeout=None):
assert op == "proxy"
return {
"status": "ok",
"event": "progress",
"request_id": "req-1",
"payload": {"chunk": {"data_base64": base64.b64encode(b"event: progress\\ndata: {\"pct\":25}\\n\\n").decode("ascii")}},
}
async def fake_wait_for_stream_event(request_id, timeout=300.0):
assert request_id == "req-1"
return {"status": "ok", "event": "done", "request_id": "req-1", "payload": {}}
async def fake_use_broker():
return True
monkeypatch.setattr(handler, "_broker_request", fake_broker_request)
monkeypatch.setattr(handler, "_use_broker", fake_use_broker)
monkeypatch.setattr("aisbf.providers.coderai.coderai_broker.wait_for_stream_event", fake_wait_for_stream_event)
status_code, payload = await handler.proxy_native_request("v1/video/progress", method="GET", stream=True)
assert status_code == 200
assert payload["stream_encoding"] == "base64"
assert base64.b64decode(payload["stream_chunks"][0]).startswith(b"event: progress")
...@@ -238,7 +238,7 @@ def test_dashboard_providers_page_includes_broker_status_for_coderai(monkeypatch ...@@ -238,7 +238,7 @@ def test_dashboard_providers_page_includes_broker_status_for_coderai(monkeypatch
assert response.status_code == 200 assert response.status_code == 200
assert "Broker Session Status" in response.text assert "Broker Session Status" in response.text
assert "workstation-01" in response.text assert "workstation-01" in response.text
assert "v1/images/generate" in response.text assert "workstation-01" in response.text
finally: finally:
asyncio.run(_clear_broker_sessions()) asyncio.run(_clear_broker_sessions())
if original_provider is None: if original_provider is None:
...@@ -264,7 +264,9 @@ def test_dashboard_api_coderai_broker_sessions_filters_by_owner(monkeypatch): ...@@ -264,7 +264,9 @@ def test_dashboard_api_coderai_broker_sessions_filters_by_owner(monkeypatch):
response = asyncio.run(dashboard_providers.api_coderai_broker_sessions(RequestStub())) response = asyncio.run(dashboard_providers.api_coderai_broker_sessions(RequestStub()))
sessions = json.loads(response.body)["sessions"] sessions = json.loads(response.body)["sessions"]
assert len(sessions) == 1 connected_sessions = [session for session in sessions if session.get("connected")]
assert sessions[0]["provider_id"] == "user-coderai" assert any(session["client_id"] == "user-client" for session in connected_sessions)
assert not any(session.get("client_id") == "global-client" for session in connected_sessions)
assert any(session["provider_id"] == "user-coderai" for session in sessions)
finally: finally:
asyncio.run(_clear_broker_sessions()) asyncio.run(_clear_broker_sessions())
...@@ -42,9 +42,10 @@ async def _clear_broker_sessions(): ...@@ -42,9 +42,10 @@ async def _clear_broker_sessions():
def test_broker_registers_websocket_session_and_reports_status(): def test_broker_registers_websocket_session_and_reports_status():
with TestClient(app) as client: with TestClient(app) as client:
with client.websocket_connect("/api/coderai/broker/ws?provider_id=coderai&client_id=nat-client&registration_token=global-token") as websocket: with client.websocket_connect("/api/coderai/wss?provider_id=coderai&client_id=nat-client&registration_token=global-token&username=global") as websocket:
registered = websocket.receive_json() registered = websocket.receive_json()
assert registered["event"] == "registered" assert registered["event"] == "registered"
assert registered["scope_name"] == "global"
websocket.send_json({ websocket.send_json({
"op": "register", "op": "register",
...@@ -69,7 +70,7 @@ def test_broker_registers_websocket_session_and_reports_status(): ...@@ -69,7 +70,7 @@ def test_broker_registers_websocket_session_and_reports_status():
def test_broker_rejects_missing_registration_token(): def test_broker_rejects_missing_registration_token():
with TestClient(app) as client: with TestClient(app) as client:
with pytest.raises(Exception): with pytest.raises(Exception):
with client.websocket_connect("/api/coderai/broker/ws?provider_id=coderai&client_id=bad-client"): with client.websocket_connect("/api/coderai/wss?provider_id=coderai&client_id=bad-client&username=global"):
pass pass
...@@ -96,7 +97,97 @@ def test_broker_routes_request_to_registered_session(): ...@@ -96,7 +97,97 @@ def test_broker_routes_request_to_registered_session():
assert sent_message["op"] == "models.list" assert sent_message["op"] == "models.list"
assert response["status"] == "ok" assert response["status"] == "ok"
assert response["payload"]["data"][0]["id"] == "llama3.1:8b" assert response["payload"]["data"][0]["id"] == "llama3.1:8b"
queued = await broker.consume_request(session.session_id, timeout=0)
assert queued is None
finally: finally:
await broker.unregister(session.session_id) await broker.unregister(session.session_id)
asyncio.run(scenario()) asyncio.run(scenario())
def test_broker_uses_queue_for_remote_node_session():
async def scenario():
class StubWebSocket:
async def send_text(self, payload: str):
raise AssertionError("Remote-node session should not use direct websocket fast path")
websocket = StubWebSocket()
session = await broker.register(websocket, "coderai", "remote-client", metadata={"owner_user_id": None})
await broker.touch(session.session_id, metadata={"broker_node_id": "remote-node"})
try:
async def responder():
message = await broker.consume_request(session.session_id, timeout=1)
assert message is not None
await asyncio.sleep(0)
await broker.publish_response({
"request_id": message["request_id"],
"status": "ok",
"payload": {"data": [{"id": "queue-model"}]},
})
response, _ = await asyncio.gather(
broker.send_request("coderai", "models.list", {}, client_id="remote-client", owner_user_id=None, timeout=3.0),
responder(),
)
assert response["payload"]["data"][0]["id"] == "queue-model"
finally:
await broker.unregister(session.session_id)
asyncio.run(scenario())
def test_broker_stream_events_are_delivered_to_waiter():
async def scenario():
class StubWebSocket:
def __init__(self):
self.sent = []
async def send_text(self, payload: str):
self.sent.append(payload)
message = json.loads(payload)
await broker.publish_response({
"request_id": message["request_id"],
"status": "ok",
"event": "progress",
"payload": {"chunk": "event: progress\ndata: {\"pct\": 50}\n\n"},
})
await broker.publish_response({
"request_id": message["request_id"],
"status": "ok",
"event": "done",
"payload": {},
})
websocket = StubWebSocket()
session = await broker.register(websocket, "coderai", "stream-client", metadata={"owner_user_id": None})
try:
response = await broker.send_request("coderai", "proxy", {"stream": True}, client_id="stream-client", owner_user_id=None, timeout=3.0)
assert response["event"] == "done"
finally:
await broker.unregister(session.session_id)
asyncio.run(scenario())
def test_user_scoped_broker_websocket_path_registers_username():
original_provider = config.providers.get("coderai-user")
config.providers["coderai-user"] = ProviderConfig(
id="coderai-user",
name="CoderAI User",
endpoint="http://127.0.0.1:11437",
type="coderai",
api_key_required=False,
rate_limit=0,
coderai_config={"registration_token": "user-token"},
)
try:
with TestClient(app) as client:
with client.websocket_connect("/api/u/alice/coderai/wss?provider_id=coderai-user&client_id=user-client&registration_token=user-token&username=alice") as websocket:
registered = websocket.receive_json()
assert registered["username"] == "alice"
assert registered["scope_name"] == "alice"
finally:
if original_provider is None:
config.providers.pop("coderai-user", None)
else:
config.providers["coderai-user"] = original_provider
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment