Honor provider/rotation/autoselect context & token defaults; bump to 0.99.81

Provider-level defaults were ignored when a provider had no per-model
configs, so coderai/broker providers reported a fixed 4096 context window
and clients fell back to their own defaults.

- model list (handlers.py): fall back to provider_config.default_context_size
  for context_window/context_length before inferring
- rotation/autoselect model list (routes/api.py): attach context_window/
  context_length resolved from rotation/autoselect/provider defaults (was
  absent entirely, causing clients to default to 4096)
- handle_rotation_model_list: honor provider default_context_size before
  heuristic auto-derivation
- max output tokens: add ProviderModelConfig.max_tokens and
  default_max_tokens on Provider/Rotation/Autoselect configs, plus
  get_max_completion_tokens_for_model resolver, applied as a fallback in
  all request paths when the client omits max_tokens
- get_max_request_tokens_for_model: fall back to
  provider_config.default_max_request_tokens; drop duplicated dead block
Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
parent 0dd04711
...@@ -53,9 +53,9 @@ from .auth.claude import ClaudeAuth ...@@ -53,9 +53,9 @@ from .auth.claude import ClaudeAuth
from .auth.kilo import KiloOAuth2 from .auth.kilo import KiloOAuth2
from .auth.qwen import QwenOAuth2 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, get_max_completion_tokens_for_model
__version__ = "0.99.79" __version__ = "0.99.81"
__all__ = [ __all__ = [
# Config # Config
"config", "config",
...@@ -101,4 +101,5 @@ __all__ = [ ...@@ -101,4 +101,5 @@ __all__ = [
"count_messages_tokens", "count_messages_tokens",
"split_messages_into_chunks", "split_messages_into_chunks",
"get_max_request_tokens_for_model", "get_max_request_tokens_for_model",
"get_max_completion_tokens_for_model",
] ]
...@@ -34,6 +34,7 @@ class ProviderModelConfig(BaseModel): ...@@ -34,6 +34,7 @@ class ProviderModelConfig(BaseModel):
name: str name: str
rate_limit: Optional[float] = None rate_limit: Optional[float] = None
max_request_tokens: Optional[int] = None max_request_tokens: Optional[int] = None
max_tokens: Optional[int] = None # Upper bound on generated output tokens (max completion tokens)
error_cooldown: Optional[int] = None # Cooldown period in seconds after 3 consecutive failures error_cooldown: Optional[int] = None # Cooldown period in seconds after 3 consecutive failures
# OpenRouter-style extended fields # OpenRouter-style extended fields
description: Optional[str] = None description: Optional[str] = None
...@@ -121,6 +122,7 @@ class ProviderConfig(BaseModel): ...@@ -121,6 +122,7 @@ class ProviderConfig(BaseModel):
# Default settings for models in this provider # Default settings for models in this provider
default_rate_limit: Optional[float] = None default_rate_limit: Optional[float] = None
default_max_request_tokens: Optional[int] = None default_max_request_tokens: Optional[int] = None
default_max_tokens: Optional[int] = None # Default upper bound on generated output tokens (max completion tokens)
default_rate_limit_TPM: Optional[int] = None default_rate_limit_TPM: Optional[int] = None
default_rate_limit_TPH: Optional[int] = None default_rate_limit_TPH: Optional[int] = None
default_rate_limit_TPD: Optional[int] = None default_rate_limit_TPD: Optional[int] = None
...@@ -174,6 +176,7 @@ class RotationConfig(BaseModel): ...@@ -174,6 +176,7 @@ class RotationConfig(BaseModel):
# Default settings for models in this rotation # Default settings for models in this rotation
default_rate_limit: Optional[float] = None default_rate_limit: Optional[float] = None
default_max_request_tokens: Optional[int] = None default_max_request_tokens: Optional[int] = None
default_max_tokens: Optional[int] = None # Default upper bound on generated output tokens (max completion tokens)
default_rate_limit_TPM: Optional[int] = None default_rate_limit_TPM: Optional[int] = None
default_rate_limit_TPH: Optional[int] = None default_rate_limit_TPH: Optional[int] = None
default_rate_limit_TPD: Optional[int] = None default_rate_limit_TPD: Optional[int] = None
...@@ -228,6 +231,7 @@ class AutoselectConfig(BaseModel): ...@@ -228,6 +231,7 @@ class AutoselectConfig(BaseModel):
# Default settings for models in this autoselect # Default settings for models in this autoselect
default_rate_limit: Optional[float] = None default_rate_limit: Optional[float] = None
default_max_request_tokens: Optional[int] = None default_max_request_tokens: Optional[int] = None
default_max_tokens: Optional[int] = None # Default upper bound on generated output tokens (max completion tokens)
default_rate_limit_TPM: Optional[int] = None default_rate_limit_TPM: Optional[int] = None
default_rate_limit_TPH: Optional[int] = None default_rate_limit_TPH: Optional[int] = None
default_rate_limit_TPD: Optional[int] = None default_rate_limit_TPD: Optional[int] = None
......
...@@ -44,7 +44,8 @@ from .studio_adapters import effective_studio_adapter, infer_studio_adapter_prof ...@@ -44,7 +44,8 @@ from .studio_adapters import effective_studio_adapter, infer_studio_adapter_prof
from .utils import ( from .utils import (
count_messages_tokens, count_messages_tokens,
split_messages_into_chunks, split_messages_into_chunks,
get_max_request_tokens_for_model get_max_request_tokens_for_model,
get_max_completion_tokens_for_model
) )
from .context import ContextManager, get_context_config_for_model from .context import ContextManager, get_context_config_for_model
from .classifier import content_classifier from .classifier import content_classifier
...@@ -705,7 +706,18 @@ class RequestHandler: ...@@ -705,7 +706,18 @@ class RequestHandler:
provider_config=provider_config, provider_config=provider_config,
rotation_model_config=None rotation_model_config=None
) )
# Apply provider-level default max output tokens when the client didn't set one
if request_data.get('max_tokens') is None:
default_max_tokens = get_max_completion_tokens_for_model(
model_name=model,
provider_config=provider_config,
rotation_model_config=None,
)
if default_max_tokens:
request_data['max_tokens'] = default_max_tokens
logger.info(f"Applied provider default max_tokens: {default_max_tokens}")
# Calculate effective context (total tokens used) # Calculate effective context (total tokens used)
effective_context = count_messages_tokens(messages, model) effective_context = count_messages_tokens(messages, model)
logger.info(f"Effective context: {effective_context} tokens") logger.info(f"Effective context: {effective_context} tokens")
...@@ -1044,7 +1056,17 @@ class RequestHandler: ...@@ -1044,7 +1056,17 @@ class RequestHandler:
rotation_config=None, rotation_config=None,
autoselect_config=None, autoselect_config=None,
) )
# Apply provider-level default max output tokens when the client didn't set one
if request_data.get('max_tokens') is None:
default_max_tokens = get_max_completion_tokens_for_model(
model_name=model,
provider_config=provider_config,
rotation_model_config=None,
)
if default_max_tokens:
request_data['max_tokens'] = default_max_tokens
effective_context = count_messages_tokens(messages, model) effective_context = count_messages_tokens(messages, model)
prompt_analysis = self._run_prompt_analysis( prompt_analysis = self._run_prompt_analysis(
...@@ -1814,15 +1836,20 @@ class RequestHandler: ...@@ -1814,15 +1836,20 @@ class RequestHandler:
elif model_dict.get('context_size'): elif model_dict.get('context_size'):
# Dynamically fetched from provider - use this value # Dynamically fetched from provider - use this value
model_dict['context_window'] = model_dict['context_size'] model_dict['context_window'] = model_dict['context_size']
elif getattr(provider_config, 'default_context_size', None):
# Provider-level default (applies when no per-model config exists)
model_dict['context_window'] = provider_config.default_context_size
else: else:
# Fall back to inference # Fall back to inference
model_dict['context_window'] = self._infer_context_window(model_name, provider_config.type) model_dict['context_window'] = self._infer_context_window(model_name, provider_config.type)
# Add context_length for compatibility - same priority order as context_window # Add context_length for compatibility - same priority order as context_window
if model_config and hasattr(model_config, 'context_size') and model_config.context_size: if model_config and hasattr(model_config, 'context_size') and model_config.context_size:
model_dict['context_length'] = model_config.context_size model_dict['context_length'] = model_config.context_size
elif model_dict.get('context_size'): elif model_dict.get('context_size'):
model_dict['context_length'] = model_dict['context_size'] model_dict['context_length'] = model_dict['context_size']
elif getattr(provider_config, 'default_context_size', None):
model_dict['context_length'] = provider_config.default_context_size
elif model_dict.get('context_length'): elif model_dict.get('context_length'):
model_dict['context_length'] = model_dict['context_length'] model_dict['context_length'] = model_dict['context_length']
...@@ -1914,15 +1941,20 @@ class RequestHandler: ...@@ -1914,15 +1941,20 @@ class RequestHandler:
elif model_dict.get('context_size'): elif model_dict.get('context_size'):
# Dynamically fetched from provider - use this value # Dynamically fetched from provider - use this value
model_dict['context_window'] = model_dict['context_size'] model_dict['context_window'] = model_dict['context_size']
elif getattr(provider_config, 'default_context_size', None):
# Provider-level default (applies when no per-model config exists)
model_dict['context_window'] = provider_config.default_context_size
else: else:
# Fall back to inference # Fall back to inference
model_dict['context_window'] = self._infer_context_window(model_name, provider_config.type) model_dict['context_window'] = self._infer_context_window(model_name, provider_config.type)
# Add context_length for compatibility - same priority order as context_window # Add context_length for compatibility - same priority order as context_window
if model_config and hasattr(model_config, 'context_size') and model_config.context_size: if model_config and hasattr(model_config, 'context_size') and model_config.context_size:
model_dict['context_length'] = model_config.context_size model_dict['context_length'] = model_config.context_size
elif model_dict.get('context_size'): elif model_dict.get('context_size'):
model_dict['context_length'] = model_dict['context_size'] model_dict['context_length'] = model_dict['context_size']
elif getattr(provider_config, 'default_context_size', None):
model_dict['context_length'] = provider_config.default_context_size
elif model_dict.get('context_length'): elif model_dict.get('context_length'):
model_dict['context_length'] = model_dict['context_length'] model_dict['context_length'] = model_dict['context_length']
...@@ -3634,6 +3666,18 @@ class RotationHandler: ...@@ -3634,6 +3666,18 @@ class RotationHandler:
# Update request_data with condensed messages # Update request_data with condensed messages
request_data['messages'] = messages request_data['messages'] = messages
# Apply rotation-level default max output tokens when the client didn't set one
if request_data.get('max_tokens') is None:
default_max_tokens = get_max_completion_tokens_for_model(
model_name=model_name,
provider_config=None,
rotation_model_config=current_model,
rotation_config=rotation_config,
)
if default_max_tokens:
request_data['max_tokens'] = default_max_tokens
logger.info(f"Applied rotation default max_tokens: {default_max_tokens}")
# Check for max_request_tokens in rotation model config # Check for max_request_tokens in rotation model config
max_request_tokens = current_model.get('max_request_tokens') max_request_tokens = current_model.get('max_request_tokens')
if max_request_tokens: if max_request_tokens:
...@@ -5087,8 +5131,12 @@ class RotationHandler: ...@@ -5087,8 +5131,12 @@ class RotationHandler:
found_in_provider = True found_in_provider = True
break break
if not found_in_provider: if not found_in_provider:
provider_default = getattr(provider_config, 'default_context_size', None)
if provider_default:
# Provider-level default takes precedence over heuristic auto-derivation
model_dict['context_window'] = provider_default
# Auto-derive from first model in provider (which has context_size from dynamic fetch) # Auto-derive from first model in provider (which has context_size from dynamic fetch)
if getattr(provider_config, "models", []) and len(getattr(provider_config, "models", [])) > 0: elif getattr(provider_config, "models", []) and len(getattr(provider_config, "models", [])) > 0:
first_model = getattr(provider_config, "models", [])[0] first_model = getattr(provider_config, "models", [])[0]
if hasattr(first_model, 'context_size') and first_model.context_size: if hasattr(first_model, 'context_size') and first_model.context_size:
model_dict['context_window'] = first_model.context_size model_dict['context_window'] = first_model.context_size
......
...@@ -7,6 +7,7 @@ from aisbf.models import ChatCompletionRequest ...@@ -7,6 +7,7 @@ from aisbf.models import ChatCompletionRequest
from aisbf.database import DatabaseRegistry from aisbf.database import DatabaseRegistry
from aisbf.app.model_cache import get_provider_models, _refresh_provider_usage_if_stale, _background_tasks from aisbf.app.model_cache import get_provider_models, _refresh_provider_usage_if_stale, _background_tasks
from aisbf.studio_services import studio_service from aisbf.studio_services import studio_service
from aisbf.context import get_context_config_for_model
router = APIRouter() router = APIRouter()
_config = None _config = None
...@@ -205,6 +206,70 @@ async def v1_chat_completions(request: Request, body: ChatCompletionRequest): ...@@ -205,6 +206,70 @@ async def v1_chat_completions(request: Request, body: ChatCompletionRequest):
else: else:
return await handler.handle_chat_completion(request, provider_id, body_dict) return await handler.handle_chat_completion(request, provider_id, body_dict)
def _resolve_rotation_context(rotation_config) -> Optional[int]:
"""Resolve the advertised context window for a rotation.
Priority: explicit rotation context_length > rotation default_context_size >
the largest context window resolvable across the rotation's member models
(which honors each provider's per-model and default_context_size). Returns
None only if nothing can be resolved.
"""
explicit = getattr(rotation_config, 'context_length', None) or getattr(rotation_config, 'default_context_size', None)
if explicit:
return explicit
best = None
for provider in getattr(rotation_config, 'providers', None) or []:
try:
provider_id = provider.get('provider_id')
provider_config = _config.get_provider(provider_id) if provider_id else None
for member in provider.get('models', []) or []:
ctx = get_context_config_for_model(
model_name=member.get('name'),
provider_config=provider_config,
rotation_model_config=member,
rotation_config=rotation_config,
).get('context_size')
if ctx and (best is None or ctx > best):
best = ctx
except Exception as e:
logger.debug(f"Could not resolve context for rotation member: {e}")
return best
def _resolve_autoselect_context(autoselect_config) -> Optional[int]:
"""Resolve the advertised context window for an autoselect.
Priority: explicit context_length > default_context_size > the largest
context window across its available models (resolving rotation or
provider/model references). Returns None if nothing can be resolved.
"""
explicit = getattr(autoselect_config, 'context_length', None) or getattr(autoselect_config, 'default_context_size', None)
if explicit:
return explicit
best = None
for member in getattr(autoselect_config, 'available_models', None) or []:
try:
model_id = getattr(member, 'model_id', None) or getattr(member, 'model_name', None)
if not model_id:
continue
ctx = None
if model_id in _config.rotations:
ctx = _resolve_rotation_context(_config.rotations[model_id])
elif '/' in model_id:
provider_id, _, model_name = model_id.partition('/')
provider_config = _config.get_provider(provider_id)
if provider_config:
ctx = get_context_config_for_model(
model_name=model_name,
provider_config=provider_config,
).get('context_size')
if ctx and (best is None or ctx > best):
best = ctx
except Exception as e:
logger.debug(f"Could not resolve context for autoselect member: {e}")
return best
async def _build_model_list(request: Request) -> dict: async def _build_model_list(request: Request) -> dict:
"""Shared model listing logic used by all /models endpoints.""" """Shared model listing logic used by all /models endpoints."""
all_models = [] all_models = []
...@@ -227,12 +292,22 @@ async def _build_model_list(request: Request) -> dict: ...@@ -227,12 +292,22 @@ async def _build_model_list(request: Request) -> dict:
logger.warning(f"Error listing models for provider {provider_id}: {e}") logger.warning(f"Error listing models for provider {provider_id}: {e}")
for rotation_id, rotation_config in _config.rotations.items(): for rotation_id, rotation_config in _config.rotations.items():
try: try:
all_models.append({'id': f"rotation/{rotation_id}", 'object': 'model', 'created': int(time.time()), 'owned_by': 'aisbf-rotation', 'type': 'rotation', 'rotation_id': rotation_id, 'model_name': rotation_config.model_name, 'capabilities': getattr(rotation_config, 'capabilities', [])}) entry = {'id': f"rotation/{rotation_id}", 'object': 'model', 'created': int(time.time()), 'owned_by': 'aisbf-rotation', 'type': 'rotation', 'rotation_id': rotation_id, 'model_name': rotation_config.model_name, 'capabilities': getattr(rotation_config, 'capabilities', [])}
rotation_ctx = _resolve_rotation_context(rotation_config)
if rotation_ctx:
entry['context_window'] = rotation_ctx
entry['context_length'] = rotation_ctx
all_models.append(entry)
except Exception as e: except Exception as e:
logger.warning(f"Error listing rotation {rotation_id}: {e}") logger.warning(f"Error listing rotation {rotation_id}: {e}")
for autoselect_id, autoselect_config in _config.autoselect.items(): for autoselect_id, autoselect_config in _config.autoselect.items():
try: try:
all_models.append({'id': f"autoselect/{autoselect_id}", 'object': 'model', 'created': int(time.time()), 'owned_by': 'aisbf-autoselect', 'type': 'autoselect', 'autoselect_id': autoselect_id, 'model_name': autoselect_config.model_name, 'description': autoselect_config.description, 'capabilities': getattr(autoselect_config, 'capabilities', [])}) entry = {'id': f"autoselect/{autoselect_id}", 'object': 'model', 'created': int(time.time()), 'owned_by': 'aisbf-autoselect', 'type': 'autoselect', 'autoselect_id': autoselect_id, 'model_name': autoselect_config.model_name, 'description': autoselect_config.description, 'capabilities': getattr(autoselect_config, 'capabilities', [])}
autoselect_ctx = _resolve_autoselect_context(autoselect_config)
if autoselect_ctx:
entry['context_window'] = autoselect_ctx
entry['context_length'] = autoselect_ctx
all_models.append(entry)
except Exception as e: except Exception as e:
logger.warning(f"Error listing autoselect {autoselect_id}: {e}") logger.warning(f"Error listing autoselect {autoselect_id}: {e}")
logger.info(f"Returning {len(all_models)} total models") logger.info(f"Returning {len(all_models)} total models")
......
...@@ -149,7 +149,8 @@ def get_max_request_tokens_for_model( ...@@ -149,7 +149,8 @@ def get_max_request_tokens_for_model(
Priority order: Priority order:
1. Check rotation model config (if provided) 1. Check rotation model config (if provided)
2. Check provider models config 2. Check provider models config
3. Fall back to provider-level default_max_request_tokens
Args: Args:
model_name: The model name to look up model_name: The model name to look up
provider_config: The provider configuration provider_config: The provider configuration
...@@ -183,11 +184,86 @@ def get_max_request_tokens_for_model( ...@@ -183,11 +184,86 @@ def get_max_request_tokens_for_model(
if max_tokens: if max_tokens:
logger.info(f"Found max_request_tokens in provider model config: {max_tokens}") logger.info(f"Found max_request_tokens in provider model config: {max_tokens}")
return max_tokens return max_tokens
# Finally fall back to the provider-level default (applies when no per-model config exists)
if isinstance(provider_config, dict):
default_max_tokens = provider_config.get('default_max_request_tokens')
else:
default_max_tokens = getattr(provider_config, 'default_max_request_tokens', None)
if default_max_tokens:
logger.info(f"Using provider default_max_request_tokens: {default_max_tokens}")
return default_max_tokens
logger.debug(f"No max_request_tokens configured for model {model_name}")
return None
def get_max_completion_tokens_for_model(
model_name: str,
provider_config=None,
rotation_model_config: Optional[Dict] = None,
rotation_config=None,
autoselect_config=None,
) -> Optional[int]:
"""
Get the max output/completion tokens (max_tokens) for a model from configuration.
This is the upper bound on generated output, distinct from max_request_tokens
(the input budget). Used as a fallback when the client request does not specify
max_tokens.
Priority order:
1. Rotation model config 'max_tokens' (if provided)
2. Provider per-model config max_tokens
3. Provider-level default_max_tokens
4. Rotation/autoselect default_max_tokens
Returns:
The max_tokens value, or None if not configured.
"""
import logging
logger = logging.getLogger(__name__)
def _get(cfg, key):
if cfg is None:
return None
if isinstance(cfg, dict):
return cfg.get(key)
return getattr(cfg, key, None)
# 1. Rotation model config (highest priority)
if rotation_model_config:
max_tokens = _get(rotation_model_config, 'max_tokens')
if max_tokens:
logger.info(f"Found max_tokens in rotation model config: {max_tokens}")
return max_tokens
# 2. Provider per-model config
if isinstance(provider_config, dict):
models = provider_config.get('models', [])
else:
models = getattr(provider_config, 'models', None) or []
if models:
for model in models:
model_name_value = model.name if hasattr(model, 'name') else model.get('name')
if model_name_value == model_name:
max_tokens = _get(model, 'max_tokens')
if max_tokens: if max_tokens:
logger.info(f"Found max_request_tokens in provider model config: {max_tokens}") logger.info(f"Found max_tokens in provider model config: {max_tokens}")
return max_tokens return max_tokens
logger.debug(f"No max_request_tokens configured for model {model_name}") # 3. Provider-level default, then 4. rotation/autoselect default
for cfg, label in (
(provider_config, 'provider'),
(rotation_config, 'rotation'),
(autoselect_config, 'autoselect'),
):
default_max_tokens = _get(cfg, 'default_max_tokens')
if default_max_tokens:
logger.info(f"Using {label} default_max_tokens: {default_max_tokens}")
return default_max_tokens
logger.debug(f"No max_tokens configured for model {model_name}")
return None return None
......
...@@ -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.79" version = "0.99.81"
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.79", version="0.99.81",
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