Compare commits

...

3 Commits

Author SHA1 Message Date
RockChinQ
2e7978317c chore(release): bump version to 4.10.2 2026-06-13 11:21:44 -04:00
RockChinQ
b7d8332cb0 feat(telemetry): include instance_create_ts in heartbeat payload
Load the instance creation timestamp from data/labels/instance_id.json
(backfilling+persisting it for instances created before the field existed),
expose it as constants.instance_create_ts, and include it in the heartbeat
payload so Space can anchor Time-To-Value / onboarding analytics on real
install time rather than first-heartbeat.

Verified: py_compile, ruff, pytest tests/unit_tests/telemetry/ (37 passed).
2026-06-13 11:13:18 -04:00
huanghuoguoguo
7fe3eedeea fix(provider): use LiteLLM input window for context length (#2243) 2026-06-13 21:27:47 +08:00
9 changed files with 68 additions and 19 deletions

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "langbot" name = "langbot"
version = "4.10.1" version = "4.10.2"
description = "Production-grade platform for building agentic IM bots" description = "Production-grade platform for building agentic IM bots"
readme = "README.md" readme = "README.md"
license-files = ["LICENSE"] license-files = ["LICENSE"]

View File

@@ -1,3 +1,3 @@
"""LangBot - Production-grade platform for building agentic IM bots""" """LangBot - Production-grade platform for building agentic IM bots"""
__version__ = '4.10.1' __version__ = '4.10.2'

View File

@@ -202,6 +202,16 @@ class LoadConfigStage(stage.BootingStage):
constants.instance_id = new_id constants.instance_id = new_id
constants.edition = ap.instance_config.data.get('system', {}).get('edition', 'community') constants.edition = ap.instance_config.data.get('system', {}).get('edition', 'community')
# Instance creation timestamp: sourced from data/labels/instance_id.json.
# Instances created before this field existed (or supplied via
# system.instance_id) won't have it, so backfill with the current time
# and persist it via the dump below — from then on it stays stable.
instance_create_ts = ap.instance_id.data.get('instance_create_ts', 0)
if not isinstance(instance_create_ts, int) or instance_create_ts <= 0:
instance_create_ts = int(time.time())
ap.instance_id.data['instance_create_ts'] = instance_create_ts
constants.instance_create_ts = instance_create_ts
print(f'LangBot instance id: {constants.instance_id}') print(f'LangBot instance id: {constants.instance_id}')
print(f'LangBot edition: {constants.edition}') print(f'LangBot edition: {constants.edition}')

View File

@@ -75,22 +75,33 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
continue continue
return False return False
@staticmethod
def _positive_int(value: typing.Any) -> int | None:
if isinstance(value, bool):
return None
if isinstance(value, int) and value > 0:
return value
if isinstance(value, str) and value.isdigit():
parsed_value = int(value)
if parsed_value > 0:
return parsed_value
return None
def _context_length_from_scan_payload(self, model_payload: dict[str, typing.Any] | None) -> int | None: def _context_length_from_scan_payload(self, model_payload: dict[str, typing.Any] | None) -> int | None:
if not model_payload: if not model_payload:
return None return None
for field_name in ('context_length', 'context_window', 'max_context_length'): for field_name in ('context_length', 'context_window', 'max_context_length'):
value = model_payload.get(field_name) context_length = self._positive_int(model_payload.get(field_name))
if isinstance(value, bool): if context_length is not None:
continue return context_length
if isinstance(value, int) and value > 0:
return value
if isinstance(value, str) and value.isdigit():
parsed_value = int(value)
if parsed_value > 0:
return parsed_value
return None return None
def _context_length_from_litellm_model_info(self, model_info: typing.Any) -> int | None:
if isinstance(model_info, dict):
return self._positive_int(model_info.get('max_input_tokens'))
return self._positive_int(getattr(model_info, 'max_input_tokens', None))
def _metadata_provider_candidates(self, model_name: str) -> list[str]: def _metadata_provider_candidates(self, model_name: str) -> list[str]:
normalized_model_name = (model_name or '').lower() normalized_model_name = (model_name or '').lower()
candidates = [] candidates = []
@@ -126,7 +137,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
return None return None
def _safe_context_length(self, model_name: str) -> int | None: def _safe_context_length(self, model_name: str) -> int | None:
helper = getattr(litellm, 'get_max_tokens', None) helper = getattr(litellm, 'get_model_info', None)
if not callable(helper): if not callable(helper):
return self._known_context_length_fallback(model_name) return self._known_context_length_fallback(model_name)
@@ -143,11 +154,12 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
continue continue
tried_candidates.append(candidate) tried_candidates.append(candidate)
try: try:
max_tokens = helper(candidate) model_info = helper(candidate)
except Exception: except Exception:
continue continue
if isinstance(max_tokens, int) and max_tokens > 0: context_length = self._context_length_from_litellm_model_info(model_info)
return max_tokens if context_length is not None:
return context_length
return self._known_context_length_fallback(model_name) return self._known_context_length_fallback(model_name)
def _supports_function_calling(self, model_name: str) -> bool: def _supports_function_calling(self, model_name: str) -> bool:

View File

@@ -109,6 +109,7 @@ async def build_heartbeat_payload(ap: core_app.Application) -> dict:
'query_id': '', 'query_id': '',
'version': constants.semantic_version, 'version': constants.semantic_version,
'instance_id': constants.instance_id, 'instance_id': constants.instance_id,
'instance_create_ts': constants.instance_create_ts,
'edition': constants.edition, 'edition': constants.edition,
'features': features, 'features': features,
'timestamp': datetime.now(timezone.utc).isoformat(), 'timestamp': datetime.now(timezone.utc).isoformat(),

View File

@@ -16,3 +16,11 @@ debug_mode = False
edition = 'community' edition = 'community'
instance_id = '' instance_id = ''
instance_create_ts = 0
"""Unix timestamp (seconds) of when this instance was first created.
Sourced from ``data/labels/instance_id.json``. Backfilled to the current
time for instances created before this field existed, so it is always a
positive value once load_config has run.
"""

View File

@@ -1034,11 +1034,28 @@ class TestScanModels:
}, },
) )
with patch.object(litellmchat.litellm, 'get_max_tokens') as mock_get_max_tokens: with patch.object(litellmchat.litellm, 'get_model_info') as mock_get_model_info:
mock_get_max_tokens.side_effect = lambda model: 131072 if model == 'moonshot/moonshot-v1-128k' else None mock_get_model_info.side_effect = (
lambda model: {'max_input_tokens': 131072}
if model == 'moonshot/moonshot-v1-128k'
else {}
)
assert requester._safe_context_length('moonshot-v1-128k') == 131072 assert requester._safe_context_length('moonshot-v1-128k') == 131072
def test_safe_context_length_uses_litellm_max_input_tokens(self):
"""LiteLLM max_output_tokens must not be treated as the context window."""
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
with patch.object(litellmchat.litellm, 'get_model_info') as mock_get_model_info:
mock_get_model_info.return_value = {
'max_input_tokens': 128000,
'max_output_tokens': 16384,
'max_tokens': 16384,
}
assert requester._safe_context_length('gpt-4o') == 128000
def test_litellm_bool_helper_tries_moonshot_metadata_alias(self): def test_litellm_bool_helper_tries_moonshot_metadata_alias(self):
"""OpenAI-compatible Moonshot endpoints still use Moonshot metadata for abilities.""" """OpenAI-compatible Moonshot endpoints still use Moonshot metadata for abilities."""
requester = litellmchat.LiteLLMRequester( requester = litellmchat.LiteLLMRequester(
@@ -1102,7 +1119,7 @@ class TestScanModels:
}, },
) )
with patch.object(litellmchat.litellm, 'get_max_tokens', side_effect=Exception('not mapped')): with patch.object(litellmchat.litellm, 'get_model_info', side_effect=Exception('not mapped')):
assert requester._safe_context_length('deepseek-v4-pro') == 1_000_000 assert requester._safe_context_length('deepseek-v4-pro') == 1_000_000
assert requester._safe_context_length('deepseek-v4-flash') == 1_000_000 assert requester._safe_context_length('deepseek-v4-flash') == 1_000_000

View File

@@ -62,6 +62,7 @@ class TestBuildHeartbeatPayload:
assert payload['event_type'] == 'instance_heartbeat' assert payload['event_type'] == 'instance_heartbeat'
assert payload['query_id'] == '' assert payload['query_id'] == ''
assert 'instance_create_ts' in payload
assert 'timestamp' in payload assert 'timestamp' in payload
f = payload['features'] f = payload['features']
assert f['database'] == 'postgresql' assert f['database'] == 'postgresql'

2
uv.lock generated
View File

@@ -1967,7 +1967,7 @@ wheels = [
[[package]] [[package]]
name = "langbot" name = "langbot"
version = "4.10.1" version = "4.10.2"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiocqhttp" }, { name = "aiocqhttp" },