Notify client on rotation failover (banner/metadata/headers); bump to 0.99.71

When a rotation fails over to a different provider/model than the preferred
(highest-weight) pick, optionally notify the client so the provider/model
change isn't silent. Opt-in via a new notify_on_failover flag, settable
globally (rotations.json top-level) or per-rotation, mirroring notifyerrors.

On a real switch (served provider/model differs from preferred), three
mechanisms carry the notice:
- Visible banner: prepended to message content (non-streaming) or a leading
  delta chunk (streaming).
- JSON metadata: an aisbf_failover object on the body / leading stream chunk.
- HTTP headers: X-AISBF-Failover/Provider/Model/Preferred-* on the
  StreamingResponse and (for non-streaming) at the rotation route.

Applied to both the normal and chunked rotation paths. The banner is injected
after caching so cache hits don't replay a stale notice; no switch means zero
overhead and nothing emitted; default off preserves existing behavior.
Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
parent d9227cb1
...@@ -55,7 +55,7 @@ from .auth.qwen import QwenOAuth2 ...@@ -55,7 +55,7 @@ from .auth.qwen import QwenOAuth2
from .handlers import RequestHandler, RotationHandler, AutoselectHandler from .handlers import RequestHandler, RotationHandler, AutoselectHandler
from .utils import count_messages_tokens, split_messages_into_chunks, get_max_request_tokens_for_model from .utils import count_messages_tokens, split_messages_into_chunks, get_max_request_tokens_for_model
__version__ = "0.99.70" __version__ = "0.99.71"
__all__ = [ __all__ = [
# Config # Config
"config", "config",
......
...@@ -159,6 +159,7 @@ class RotationConfig(BaseModel): ...@@ -159,6 +159,7 @@ class RotationConfig(BaseModel):
model_name: str model_name: str
providers: List[Dict] providers: List[Dict]
notifyerrors: bool = False notifyerrors: bool = False
notify_on_failover: bool = False # Notify the client (banner/metadata/headers) when a failover changes provider/model
capabilities: Optional[List[str]] = None # Capabilities for this rotation capabilities: Optional[List[str]] = None # Capabilities for this rotation
# Content classification flags # Content classification flags
nsfw: bool = False # Model can handle NSFW content nsfw: bool = False # Model can handle NSFW content
...@@ -611,7 +612,11 @@ class Config: ...@@ -611,7 +612,11 @@ class Config:
# Extract global notifyerrors setting (top-level, outside rotations) # Extract global notifyerrors setting (top-level, outside rotations)
self.global_notifyerrors = data.get('notifyerrors', False) self.global_notifyerrors = data.get('notifyerrors', False)
logger.info(f"Global notifyerrors setting: {self.global_notifyerrors}") logger.info(f"Global notifyerrors setting: {self.global_notifyerrors}")
# Extract global notify_on_failover setting (top-level, outside rotations)
self.global_notify_on_failover = data.get('notify_on_failover', False)
logger.info(f"Global notify_on_failover setting: {self.global_notify_on_failover}")
# Load rotations, merging global notifyerrors with rotation-specific settings # Load rotations, merging global notifyerrors with rotation-specific settings
self.rotations = {} self.rotations = {}
for k, v in data['rotations'].items(): for k, v in data['rotations'].items():
...@@ -621,6 +626,12 @@ class Config: ...@@ -621,6 +626,12 @@ class Config:
logger.info(f"Rotation '{k}' using global notifyerrors: {self.global_notifyerrors}") logger.info(f"Rotation '{k}' using global notifyerrors: {self.global_notifyerrors}")
else: else:
logger.info(f"Rotation '{k}' has own notifyerrors: {v['notifyerrors']}") logger.info(f"Rotation '{k}' has own notifyerrors: {v['notifyerrors']}")
# If rotation doesn't have its own notify_on_failover, use global setting
if 'notify_on_failover' not in v:
v['notify_on_failover'] = self.global_notify_on_failover
logger.info(f"Rotation '{k}' using global notify_on_failover: {self.global_notify_on_failover}")
else:
logger.info(f"Rotation '{k}' has own notify_on_failover: {v['notify_on_failover']}")
self.rotations[k] = RotationConfig(**v) self.rotations[k] = RotationConfig(**v)
logger.info(f"Loaded {len(self.rotations)} rotations: {list(self.rotations.keys())}") logger.info(f"Loaded {len(self.rotations)} rotations: {list(self.rotations.keys())}")
......
This diff is collapsed.
...@@ -162,7 +162,26 @@ async def v1_chat_completions(request: Request, body: ChatCompletionRequest): ...@@ -162,7 +162,26 @@ async def v1_chat_completions(request: Request, body: ChatCompletionRequest):
user_id = getattr(request.state, 'user_id', None) user_id = getattr(request.state, 'user_id', None)
token_id = getattr(request.state, 'token_id', None) token_id = getattr(request.state, 'token_id', None)
handler = _get_user_handler('rotation', user_id) handler = _get_user_handler('rotation', user_id)
return await handler.handle_rotation_request(actual_model, body_dict, user_id, token_id) result = await handler.handle_rotation_request(actual_model, body_dict, user_id, token_id)
# Attach failover HTTP headers for non-streaming responses (streaming
# responses already carry them on the StreamingResponse). Derived from the
# aisbf_failover metadata that the handler placed in the body.
if isinstance(result, dict):
failover_meta = result.get('aisbf_failover')
if isinstance(failover_meta, dict) and failover_meta.get('switched'):
used = failover_meta.get('used', {}) or {}
preferred = failover_meta.get('preferred', {}) or {}
def _hsafe(value):
return str(value).encode('ascii', 'ignore').decode('ascii')
failover_headers = {
'X-AISBF-Failover': 'true',
'X-AISBF-Provider': _hsafe(used.get('provider', '')),
'X-AISBF-Model': _hsafe(used.get('model', '')),
'X-AISBF-Preferred-Provider': _hsafe(preferred.get('provider', '')),
'X-AISBF-Preferred-Model': _hsafe(preferred.get('model', '')),
}
return JSONResponse(content=result, headers=failover_headers)
return result
if provider_id == "autoselections": if provider_id == "autoselections":
if actual_model not in _config.autoselect: if actual_model not in _config.autoselect:
......
...@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" ...@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "aisbf" name = "aisbf"
version = "0.99.70" version = "0.99.71"
description = "AISBF - AI Service Broker Framework || AI Should Be Free - A modular proxy server for managing multiple AI provider integrations" description = "AISBF - AI Service Broker Framework || AI Should Be Free - A modular proxy server for managing multiple AI provider integrations"
readme = "README.md" readme = "README.md"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
......
...@@ -106,7 +106,7 @@ class InstallCommand(_install): ...@@ -106,7 +106,7 @@ class InstallCommand(_install):
setup( setup(
name="aisbf", name="aisbf",
version="0.99.70", version="0.99.71",
author="AISBF Contributors", author="AISBF Contributors",
author_email="stefy@nexlab.net", author_email="stefy@nexlab.net",
description="AISBF - AI Service Broker Framework || AI Should Be Free - A modular proxy server for managing multiple AI provider integrations", description="AISBF - AI Service Broker Framework || AI Should Be Free - A modular proxy server for managing multiple AI provider integrations",
......
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