Merge branch 'main' into leo/truncate-long-logs

This commit is contained in:
rltakashige authored and GitHub committed 2026-04-09 18:16:21 +01:00
commit 9e5d00a7f1
13 files changed
+317 -69

No files matched your search

+26
View File
@@ -172,11 +172,21 @@ from exo.shared.types.events import (
ChunkGenerated,
Event,
IndexedEvent,
InstanceDeleted,
TracesMerged,
)
from exo.shared.types.memory import Memory
from exo.shared.types.state import State
from exo.shared.types.text_generation import Base64Image, TextGenerationTaskParams
from exo.shared.types.tasks import (
ImageEdits as ImageEditsTask,
)
from exo.shared.types.tasks import (
ImageGeneration as ImageGenerationTask,
)
from exo.shared.types.tasks import (
TextGeneration as TextGenerationTask,
)
from exo.shared.types.worker.downloads import DownloadCompleted
from exo.shared.types.worker.instances import Instance, InstanceId, InstanceMeta
from exo.shared.types.worker.shards import Sharding
@@ -1822,9 +1832,25 @@ class API:
await queue.send(event.chunk)
except (BrokenResourceError, ClosedResourceError):
self._text_generation_queues.pop(event.command_id, None)
if isinstance(event, InstanceDeleted):
self._close_streams_for_instance(event.instance_id)
if isinstance(event, TracesMerged):
self._save_merged_trace(event)
def _close_streams_for_instance(self, instance_id: InstanceId) -> None:
"""Close any active generation streams for commands running on the given instance."""
for task in self.state.tasks.values():
if task.instance_id != instance_id:
continue
if not isinstance(
task, (TextGenerationTask, ImageGenerationTask, ImageEditsTask)
):
continue
if sender := self._text_generation_queues.pop(task.command_id, None):
sender.close()
if sender := self._image_generation_queues.pop(task.command_id, None):
sender.close()
def _save_merged_trace(self, event: TracesMerged) -> None:
traces = [
TraceEvent(
@@ -0,0 +1,93 @@
# pyright: reportUnusedFunction=false, reportAny=false
"""Tests that InstanceDeleted events close active generation streams."""
from unittest.mock import MagicMock
from exo.api.main import API
from exo.api.types import ImageGenerationTaskParams
from exo.shared.types.common import CommandId, ModelId
from exo.shared.types.state import State
from exo.shared.types.tasks import ImageGeneration, TextGeneration
from exo.shared.types.text_generation import InputMessage, TextGenerationTaskParams
from exo.shared.types.worker.instances import InstanceId
def _make_api_with_state(state: State) -> API:
"""Create a minimal API instance with pre-set state."""
api = object.__new__(API)
api.state = state
api._text_generation_queues = {} # pyright: ignore[reportPrivateUsage]
api._image_generation_queues = {} # pyright: ignore[reportPrivateUsage]
return api
def _make_text_gen_task(
instance_id: InstanceId, command_id: CommandId
) -> TextGeneration:
return TextGeneration(
instance_id=instance_id,
command_id=command_id,
task_params=TextGenerationTaskParams(
model=ModelId("test-model"),
input=[InputMessage(role="user", content="hello")],
),
)
def test_close_streams_for_deleted_instance() -> None:
"""Deleting an instance closes the text generation sender for commands on that instance."""
instance_id = InstanceId("inst-1")
command_id = CommandId("cmd-1")
task = _make_text_gen_task(instance_id, command_id)
state = State(tasks={task.task_id: task})
api = _make_api_with_state(state)
sender = MagicMock()
api._text_generation_queues[command_id] = sender # pyright: ignore[reportPrivateUsage]
api._close_streams_for_instance(instance_id) # pyright: ignore[reportPrivateUsage]
sender.close.assert_called_once()
assert command_id not in api._text_generation_queues # pyright: ignore[reportPrivateUsage]
def test_close_streams_ignores_unrelated_instances() -> None:
"""Deleting an instance does NOT close streams for commands on other instances."""
target_id = InstanceId("inst-delete")
other_id = InstanceId("inst-keep")
other_cmd = CommandId("cmd-keep")
other_task = _make_text_gen_task(other_id, other_cmd)
state = State(tasks={other_task.task_id: other_task})
api = _make_api_with_state(state)
sender = MagicMock()
api._text_generation_queues[other_cmd] = sender # pyright: ignore[reportPrivateUsage]
api._close_streams_for_instance(target_id) # pyright: ignore[reportPrivateUsage]
sender.close.assert_not_called()
assert other_cmd in api._text_generation_queues # pyright: ignore[reportPrivateUsage]
def test_close_streams_for_deleted_instance_image_generation() -> None:
"""Deleting an instance closes the image generation sender for commands on that instance."""
instance_id = InstanceId("inst-img")
command_id = CommandId("cmd-img")
task = ImageGeneration(
instance_id=instance_id,
command_id=command_id,
task_params=ImageGenerationTaskParams(prompt="a cat", model="test-model"),
)
state = State(tasks={task.task_id: task})
api = _make_api_with_state(state)
sender = MagicMock()
api._image_generation_queues[command_id] = sender # pyright: ignore[reportPrivateUsage]
api._close_streams_for_instance(instance_id) # pyright: ignore[reportPrivateUsage]
sender.close.assert_called_once()
assert command_id not in api._image_generation_queues # pyright: ignore[reportPrivateUsage]
+1 -1
View File
@@ -98,4 +98,4 @@ EXO_TRACING_ENABLED = os.getenv("EXO_TRACING_ENABLED", "false").lower() == "true
EXO_MAX_CONCURRENT_REQUESTS = int(os.getenv("EXO_MAX_CONCURRENT_REQUESTS", "8"))
EXO_MAX_INSTANCE_RETRIES = 3
EXO_MAX_INSTANCE_RETRIES = 5
@@ -45,7 +45,6 @@ from exo.worker.engines.mlx.patches.opt_batch_gen import (
take_ready_topk,
)
from exo.worker.engines.mlx.utils_mlx import (
detect_thinking_prompt_suffix,
fix_unmatched_think_end_tokens,
system_prompt_token_count,
)
@@ -82,8 +81,6 @@ class _EngineTask:
potential_stop_sequence_text: str = ""
completion_tokens: int = 0
generation_start_time: float = 0.0
in_thinking: bool = False
reasoning_tokens: int = 0
prefill_tps: float = 0.0
media_regions: list[MediaRegion] = field(default_factory=list)
first_gen_token_time: float | None = None
@@ -273,7 +270,6 @@ class ExoBatchGenerator:
generation_start_time=time.perf_counter(),
prefill_tps=_prefill_tps,
media_regions=media_regions,
in_thinking=detect_thinking_prompt_suffix(prompt, self.tokenizer),
)
return uid
@@ -323,15 +319,6 @@ class ExoBatchGenerator:
state.generated_text_parts.append(text)
state.potential_stop_sequence_text += text
think_start = self.tokenizer.think_start
think_end = self.tokenizer.think_end
if think_start is not None and text == think_start:
state.in_thinking = True
elif think_end is not None and text == think_end:
state.in_thinking = False
if state.in_thinking:
state.reasoning_tokens += 1
finish_reason: FinishReason | None = cast(
FinishReason | None, response.finish_reason
)
@@ -402,7 +389,7 @@ class ExoBatchGenerator:
cached_tokens=state.prefix_hit_length
),
completion_tokens_details=CompletionTokensDetails(
reasoning_tokens=state.reasoning_tokens
reasoning_tokens=0
),
)
@@ -57,7 +57,6 @@ from exo.worker.engines.mlx.constants import (
)
from exo.worker.engines.mlx.utils_mlx import (
apply_chat_template,
detect_thinking_prompt_suffix,
fix_unmatched_think_end_tokens,
mx_barrier,
system_prompt_token_count,
@@ -603,11 +602,6 @@ def mlx_generate(
generated_text_parts: list[str] = []
generation_start_time = time.perf_counter()
usage: Usage | None = None
in_thinking = detect_thinking_prompt_suffix(prompt, tokenizer)
reasoning_tokens = 0
think_start = tokenizer.think_start
think_end = tokenizer.think_end
logger.info("Starting decode")
mx_barrier(group)
@@ -629,13 +623,6 @@ def mlx_generate(
generated_text_parts.append(out.text)
accumulated_text += out.text
if think_start is not None and out.text == think_start:
in_thinking = True
elif think_end is not None and out.text == think_end:
in_thinking = False
if in_thinking:
reasoning_tokens += 1
# Check for stop sequences
text = out.text
finish_reason: FinishReason | None = cast(
@@ -679,9 +666,7 @@ def mlx_generate(
prompt_tokens_details=PromptTokensDetails(
cached_tokens=prefix_hit_length
),
completion_tokens_details=CompletionTokensDetails(
reasoning_tokens=reasoning_tokens
),
completion_tokens_details=CompletionTokensDetails(reasoning_tokens=0),
)
# Extract logprobs from the full vocabulary logprobs array
+7
View File
@@ -40,6 +40,7 @@ from exo.shared.types.tasks import (
CreateRunner,
DownloadModel,
ImageEdits,
LoadModel,
Shutdown,
Task,
TaskStatus,
@@ -351,6 +352,12 @@ class Worker:
if cmd_id in self.input_chunk_counts:
del self.input_chunk_counts[cmd_id]
await self._start_runner_task(modified_task)
case LoadModel(instance_id=instance_id):
if (instance := self.state.instances.get(instance_id)) is not None:
model_id = instance.shard_assignments.model_id
self._download_backoff.reset(model_id)
await self._start_runner_task(task)
case task:
await self._start_runner_task(task)
+19 -6
View File
@@ -59,7 +59,7 @@ def plan(
return (
_cancel_tasks(runners, tasks)
or _kill_runner(runners, all_runners, instances)
or _create_runner(node_id, runners, instances, instance_backoff)
or _create_runner(node_id, runners, all_runners, instances, instance_backoff)
or _model_needs_download(
node_id, runners, global_download_status, download_backoff
)
@@ -79,6 +79,11 @@ def _kill_runner(
runner_id = runner.bound_instance.bound_runner_id
if (instance_id := runner.bound_instance.instance.instance_id) not in instances:
return Shutdown(instance_id=instance_id, runner_id=runner_id)
if isinstance(runner.status, RunnerFailed):
return Shutdown(
instance_id=runner.bound_instance.instance.instance_id,
runner_id=runner_id,
)
for (
global_runner_id
@@ -96,13 +101,11 @@ def _kill_runner(
def _create_runner(
node_id: NodeId,
runners: Mapping[RunnerId, RunnerSupervisor],
all_runners: Mapping[RunnerId, RunnerStatus],
instances: Mapping[InstanceId, Instance],
instance_backoff: KeyedBackoff[InstanceId],
) -> CreateRunner | None:
for instance in instances.values():
if not instance_backoff.should_proceed(instance.instance_id):
continue
runner_id = instance.shard_assignments.node_to_runner.get(node_id, None)
if runner_id is None:
continue
@@ -110,8 +113,18 @@ def _create_runner(
if runner_id in runners:
continue
shard = instance.shard(runner_id)
assert shard is not None
# don't create runners if any other nodes have runners that have failed - wait for them to fix themselves first.
instance_has_failed_runner = any(
isinstance(all_runners.get(remote_runner_id), RunnerFailed)
for remote_runner_id in instance.shard_assignments.node_to_runner.values()
if remote_runner_id != runner_id
)
we_have_failed_before = isinstance(all_runners.get(runner_id), RunnerFailed)
if instance_has_failed_runner and not we_have_failed_before:
continue
if not instance_backoff.should_proceed(instance.instance_id):
continue
return CreateRunner(
instance_id=instance.instance_id,
+1 -4
View File
@@ -43,7 +43,6 @@ from exo.shared.types.worker.runner_response import (
from exo.shared.types.worker.runners import (
RunnerConnected,
RunnerConnecting,
RunnerFailed,
RunnerIdle,
RunnerLoaded,
RunnerLoading,
@@ -331,9 +330,7 @@ class Runner:
def handle_task(self, task: Task):
match task:
case ConnectToGroup() if isinstance(
self.current_status, (RunnerIdle, RunnerFailed)
):
case ConnectToGroup() if isinstance(self.current_status, RunnerIdle):
logger.info("runner connecting")
self.update_status(RunnerConnecting())
self.acknowledge_task(task)
@@ -30,6 +30,32 @@ def get_gpt_oss_encoding():
return encoding
def count_reasoning_tokens(
responses: Generator[GenerationResponse | ToolCallResponse | None],
) -> Generator[GenerationResponse | ToolCallResponse | None]:
"""Count tokens with is_thinking=True and patch the total into Usage on the final response."""
reasoning_tokens = 0
for response in responses:
if response is None:
yield None
continue
if isinstance(response, GenerationResponse) and response.is_thinking:
reasoning_tokens += 1
if response.usage is not None and reasoning_tokens > 0:
response = response.model_copy(
update={
"usage": response.usage.model_copy(
update={
"completion_tokens_details": response.usage.completion_tokens_details.model_copy(
update={"reasoning_tokens": reasoning_tokens}
)
}
)
}
)
yield response
def apply_all_parsers(
receiver: Generator[GenerationResponse | None],
prompt: str,
@@ -41,14 +67,6 @@ def apply_all_parsers(
) -> Generator[GenerationResponse | ToolCallResponse | None]:
mlx_generator = receiver
if tokenizer.has_thinking:
mlx_generator = parse_thinking_models(
mlx_generator,
tokenizer.think_start,
tokenizer.think_end,
starts_in_thinking=detect_thinking_prompt_suffix(prompt, tokenizer),
)
if issubclass(model_type, GptOssModel):
mlx_generator = parse_gpt_oss(mlx_generator)
elif (
@@ -56,10 +74,19 @@ def apply_all_parsers(
and "deepseek" in model_id.normalize().lower()
):
mlx_generator = parse_deepseek_v32(mlx_generator)
elif tool_parser:
mlx_generator = parse_tool_calls(mlx_generator, tool_parser, tools)
else:
if tokenizer.has_thinking:
mlx_generator = parse_thinking_models(
mlx_generator,
tokenizer.think_start,
tokenizer.think_end,
starts_in_thinking=detect_thinking_prompt_suffix(prompt, tokenizer),
)
return mlx_generator
if tool_parser:
mlx_generator = parse_tool_calls(mlx_generator, tool_parser, tools)
return count_reasoning_tokens(mlx_generator)
def parse_gpt_oss(
@@ -67,7 +94,6 @@ def parse_gpt_oss(
) -> Generator[GenerationResponse | ToolCallResponse | None]:
encoding = get_gpt_oss_encoding()
stream = StreamableParser(encoding, role=Role.ASSISTANT)
thinking = False
current_tool_name: str | None = None
tool_arg_parts: list[str] = []
@@ -121,14 +147,10 @@ def parse_gpt_oss(
tool_arg_parts = []
continue
if ch == "analysis" and not thinking:
thinking = True
if ch != "analysis" and thinking:
thinking = False
if delta:
yield response.model_copy(update={"text": delta, "is_thinking": thinking})
yield response.model_copy(
update={"text": delta, "is_thinking": ch == "analysis"}
)
if response.finish_reason is not None:
yield response
@@ -149,9 +149,7 @@ class Runner:
self.send_task_status(task.task_id, TaskStatus.Running)
match task:
case ConnectToGroup() if isinstance(
self.current_status, (RunnerIdle, RunnerFailed)
):
case ConnectToGroup() if isinstance(self.current_status, RunnerIdle):
assert isinstance(self.generator, Builder)
logger.info("runner connecting")
self.update_status(RunnerConnecting())
+1 -3
View File
@@ -276,9 +276,7 @@ class RunnerSupervisor:
await self._event_sender.send(
RunnerStatusUpdated(
runner_id=self.bound_instance.bound_runner_id,
runner_status=RunnerFailed(
error_message=f"Terminated ({cause})"
),
runner_status=self.status,
)
)
except (ClosedResourceError, BrokenResourceError):
@@ -1,6 +1,7 @@
from collections.abc import Generator
from typing import Any
from exo.api.types import CompletionTokensDetails, PromptTokensDetails, Usage
from exo.shared.types.worker.runner_response import (
FinishReason,
GenerationResponse,
@@ -14,6 +15,7 @@ from exo.worker.engines.mlx.dsml_encoding import (
TOOL_CALLS_START,
)
from exo.worker.runner.llm_inference.model_output_parsers import (
count_reasoning_tokens,
parse_deepseek_v32,
parse_thinking_models,
parse_tool_calls,
@@ -243,6 +245,77 @@ class TestThinkingModelsFinishReason:
)
assert _got_finish(results)
def test_reasoning_tokens_counted(self):
"""reasoning_tokens in Usage reflects the number of thinking tokens."""
usage = Usage(
prompt_tokens=10,
completion_tokens=4,
total_tokens=14,
prompt_tokens_details=PromptTokensDetails(cached_tokens=0),
completion_tokens_details=CompletionTokensDetails(reasoning_tokens=0),
)
tokens = [
_make_response("<think>", 0),
_make_response("let me", 1),
_make_response(" think", 2),
_make_response("</think>", 3),
GenerationResponse(text="42", token=4, finish_reason="stop", usage=usage),
]
results = _step_until_finish(
count_reasoning_tokens(
parse_thinking_models(
_queue_source(tokens),
think_start="<think>",
think_end="</think>",
starts_in_thinking=False,
)
)
)
final = [
r
for r in results
if isinstance(r, GenerationResponse) and r.finish_reason is not None
]
assert len(final) == 1
assert final[0].usage is not None
assert final[0].usage.completion_tokens_details.reasoning_tokens == 2
def test_reasoning_tokens_starts_in_thinking(self):
"""reasoning_tokens counts correctly when starts_in_thinking=True."""
usage = Usage(
prompt_tokens=10,
completion_tokens=3,
total_tokens=13,
prompt_tokens_details=PromptTokensDetails(cached_tokens=0),
completion_tokens_details=CompletionTokensDetails(reasoning_tokens=0),
)
tokens = [
_make_response("hmm", 0),
_make_response("ok", 1),
_make_response("</think>", 2),
GenerationResponse(
text="answer", token=3, finish_reason="stop", usage=usage
),
]
results = _step_until_finish(
count_reasoning_tokens(
parse_thinking_models(
_queue_source(tokens),
think_start="<think>",
think_end="</think>",
starts_in_thinking=True,
)
)
)
final = [
r
for r in results
if isinstance(r, GenerationResponse) and r.finish_reason is not None
]
assert len(final) == 1
assert final[0].usage is not None
assert final[0].usage.completion_tokens_details.reasoning_tokens == 2
# ── parse_tool_calls (generic) ──────────────────────────────────
@@ -1,11 +1,19 @@
from collections.abc import Generator
from exo.api.types import FinishReason
from exo.api.types import (
CompletionTokensDetails,
FinishReason,
PromptTokensDetails,
Usage,
)
from exo.shared.types.worker.runner_response import (
GenerationResponse,
ToolCallResponse,
)
from exo.worker.runner.llm_inference.model_output_parsers import parse_gpt_oss
from exo.worker.runner.llm_inference.model_output_parsers import (
count_reasoning_tokens,
parse_gpt_oss,
)
# Token IDs from mlx-community/gpt-oss-20b-MXFP4-Q8 tokenizer.
# These are stable since they come from the model's vocabulary.
@@ -85,6 +93,7 @@ THINKING_THEN_TOOL_TOKENS: list[tuple[int, str]] = [
def _make_gen_responses(
tokens: list[tuple[int, str]],
last_finish_reason: FinishReason = "stop",
last_usage: Usage | None = None,
) -> list[GenerationResponse]:
"""Build GenerationResponse list from (token_id, text) pairs."""
responses: list[GenerationResponse] = []
@@ -95,7 +104,7 @@ def _make_gen_responses(
text=text,
token=tid,
finish_reason=last_finish_reason if is_last else None,
usage=None,
usage=last_usage if is_last else None,
)
)
return responses
@@ -251,3 +260,43 @@ class TestParseGptOssMaxTokensTruncation:
# due to Harmony encoding, so we just check something was emitted)
all_text = "".join(r.text for r in gen_responses)
assert len(all_text) > 0
class TestGptOssReasoningTokensCounted:
"""count_reasoning_tokens must patch Usage when parse_gpt_oss emits thinking tokens."""
def test_thinking_then_text_counts_reasoning_tokens(self):
usage = Usage(
prompt_tokens=10,
completion_tokens=len(PLAIN_TEXT_TOKENS),
total_tokens=10 + len(PLAIN_TEXT_TOKENS),
prompt_tokens_details=PromptTokensDetails(cached_tokens=0),
completion_tokens_details=CompletionTokensDetails(reasoning_tokens=0),
)
responses = _make_gen_responses(PLAIN_TEXT_TOKENS, last_usage=usage)
def _gen() -> Generator[GenerationResponse, None, None]:
yield from responses
results = list(
x for x in count_reasoning_tokens(parse_gpt_oss(_gen())) if x is not None
)
# Verify thinking tokens were detected
thinking = [
r for r in results if isinstance(r, GenerationResponse) and r.is_thinking
]
assert len(thinking) > 0
# Verify reasoning_tokens is patched on responses that carry Usage
with_usage = [
r
for r in results
if isinstance(r, GenerationResponse) and r.usage is not None
]
assert len(with_usage) > 0
assert all(
r.usage is not None
and r.usage.completion_tokens_details.reasoning_tokens == len(thinking)
for r in with_usage
)