← back to Exo
Parse GPT OSS in runner (#1160)
a735dad6678b7ae2a89be85e4dc2d84b1d3acd8e · 2026-01-15 19:53:55 +0000 · rltakashige
## Motivation
Simplification of API + moving model specific code to the runner
<!-- Why is this change needed? What problem does it solve? -->
<!-- If it fixes an open issue, please link to the issue here -->
## Test Plan
### Manual Testing
Tested that GPT OSS outputs are parsed correctly on the dashboard.
### Automated Testing
<!-- Describe changes to automated tests, or how existing tests cover
this change -->
<!-- - -->
Files touched
M justfileM src/exo/master/api.pyM src/exo/worker/engines/mlx/utils_mlx.pyM src/exo/worker/runner/runner.py
Diff
commit a735dad6678b7ae2a89be85e4dc2d84b1d3acd8e
Author: rltakashige <rl.takashige@gmail.com>
Date: Thu Jan 15 19:53:55 2026 +0000
Parse GPT OSS in runner (#1160)
## Motivation
Simplification of API + moving model specific code to the runner
<!-- Why is this change needed? What problem does it solve? -->
<!-- If it fixes an open issue, please link to the issue here -->
## Test Plan
### Manual Testing
Tested that GPT OSS outputs are parsed correctly on the dashboard.
### Automated Testing
<!-- Describe changes to automated tests, or how existing tests cover
this change -->
<!-- - -->
---
justfile | 2 +
src/exo/master/api.py | 75 ++++++---------------------------
src/exo/worker/engines/mlx/utils_mlx.py | 2 +
src/exo/worker/runner/runner.py | 58 ++++++++++++++++++++++++-
4 files changed, 74 insertions(+), 63 deletions(-)
diff --git a/justfile b/justfile
index 0a82d616..d8da7690 100644
--- a/justfile
+++ b/justfile
@@ -1,3 +1,5 @@
+export NIX_CONFIG := "extra-experimental-features = nix-command flakes"
+
fmt:
nix fmt
diff --git a/src/exo/master/api.py b/src/exo/master/api.py
index 30f87e2a..c24ab75b 100644
--- a/src/exo/master/api.py
+++ b/src/exo/master/api.py
@@ -13,12 +13,6 @@ from hypercorn.asyncio import serve # pyright: ignore[reportUnknownVariableType
from hypercorn.config import Config
from hypercorn.typing import ASGIFramework
from loguru import logger
-from openai_harmony import ( # pyright: ignore[reportMissingTypeStubs]
- HarmonyEncodingName,
- Role,
- StreamableParser,
- load_harmony_encoding,
-)
from exo.master.placement import place_instance as get_instance_placements
from exo.shared.apply import apply
@@ -67,8 +61,6 @@ from exo.utils.channels import Receiver, Sender, channel
from exo.utils.dashboard_path import find_dashboard
from exo.utils.event_buffer import OrderedBuffer
-encoding = load_harmony_encoding(HarmonyEncodingName.HARMONY_GPT_OSS)
-
def chunk_to_response(
chunk: TokenChunk, command_id: CommandId
@@ -381,35 +373,8 @@ class API:
instance_id=instance_id,
)
- async def _process_gpt_oss(self, token_chunks: Receiver[TokenChunk]):
- stream = StreamableParser(encoding, role=Role.ASSISTANT)
- thinking = False
-
- async for chunk in token_chunks:
- stream.process(chunk.token_id)
-
- delta = stream.last_content_delta
- ch = stream.current_channel
-
- if ch == "analysis" and not thinking:
- thinking = True
- yield chunk.model_copy(update={"text": "<think>"})
-
- if ch != "analysis" and thinking:
- thinking = False
- yield chunk.model_copy(update={"text": "</think>"})
-
- if delta:
- yield chunk.model_copy(update={"text": delta})
-
- if chunk.finish_reason is not None:
- if thinking:
- yield chunk.model_copy(update={"text": "</think>"})
- yield chunk
- break
-
async def _chat_chunk_stream(
- self, command_id: CommandId, parse_gpt_oss: bool
+ self, command_id: CommandId
) -> AsyncGenerator[TokenChunk, None]:
"""Yield `TokenChunk`s for a given command until completion."""
@@ -417,16 +382,10 @@ class API:
self._chat_completion_queues[command_id], recv = channel[TokenChunk]()
with recv as token_chunks:
- if parse_gpt_oss:
- async for chunk in self._process_gpt_oss(token_chunks):
- yield chunk
- if chunk.finish_reason is not None:
- break
- else:
- async for chunk in token_chunks:
- yield chunk
- if chunk.finish_reason is not None:
- break
+ async for chunk in token_chunks:
+ yield chunk
+ if chunk.finish_reason is not None:
+ break
except anyio.get_cancelled_exc_class():
# TODO: TaskCancelled
@@ -442,11 +401,11 @@ class API:
del self._chat_completion_queues[command_id]
async def _generate_chat_stream(
- self, command_id: CommandId, parse_gpt_oss: bool
+ self, command_id: CommandId
) -> AsyncGenerator[str, None]:
"""Generate chat completion stream as JSON strings."""
- async for chunk in self._chat_chunk_stream(command_id, parse_gpt_oss):
+ async for chunk in self._chat_chunk_stream(command_id):
chunk_response: ChatCompletionResponse = chunk_to_response(
chunk, command_id
)
@@ -458,7 +417,7 @@ class API:
yield "data: [DONE]\n\n"
async def _collect_chat_completion(
- self, command_id: CommandId, parse_gpt_oss: bool
+ self, command_id: CommandId
) -> ChatCompletionResponse:
"""Collect all token chunks for a chat completion and return a single response."""
@@ -466,7 +425,7 @@ class API:
model: str | None = None
finish_reason: FinishReason | None = None
- async for chunk in self._chat_chunk_stream(command_id, parse_gpt_oss):
+ async for chunk in self._chat_chunk_stream(command_id):
if model is None:
model = chunk.model
@@ -495,7 +454,7 @@ class API:
)
async def _collect_chat_completion_with_stats(
- self, command_id: CommandId, parse_gpt_oss: bool
+ self, command_id: CommandId
) -> BenchChatCompletionResponse:
text_parts: list[str] = []
model: str | None = None
@@ -503,7 +462,7 @@ class API:
stats: GenerationStats | None = None
- async for chunk in self._chat_chunk_stream(command_id, parse_gpt_oss):
+ async for chunk in self._chat_chunk_stream(command_id):
if model is None:
model = chunk.model
@@ -544,8 +503,6 @@ class API:
"""Handle chat completions, supporting both streaming and non-streaming responses."""
model_meta = await resolve_model_meta(payload.model)
payload.model = model_meta.model_id
- parse_gpt_oss = "gpt-oss" in model_meta.model_id.lower()
- logger.info(f"{parse_gpt_oss=}")
if not any(
instance.shard_assignments.model_id == payload.model
@@ -562,17 +519,16 @@ class API:
await self._send(command)
if payload.stream:
return StreamingResponse(
- self._generate_chat_stream(command.command_id, parse_gpt_oss),
+ self._generate_chat_stream(command.command_id),
media_type="text/event-stream",
)
- return await self._collect_chat_completion(command.command_id, parse_gpt_oss)
+ return await self._collect_chat_completion(command.command_id)
async def bench_chat_completions(
self, payload: BenchChatCompletionTaskParams
) -> BenchChatCompletionResponse:
model_meta = await resolve_model_meta(payload.model)
- parse_gpt_oss = "gpt-oss" in model_meta.model_id.lower()
payload.model = model_meta.model_id
if not any(
@@ -589,10 +545,7 @@ class API:
command = ChatCompletion(request_params=payload)
await self._send(command)
- response = await self._collect_chat_completion_with_stats(
- command.command_id,
- parse_gpt_oss,
- )
+ response = await self._collect_chat_completion_with_stats(command.command_id)
return response
def _calculate_total_available_memory(self) -> Memory:
diff --git a/src/exo/worker/engines/mlx/utils_mlx.py b/src/exo/worker/engines/mlx/utils_mlx.py
index 64807eb6..81fc68aa 100644
--- a/src/exo/worker/engines/mlx/utils_mlx.py
+++ b/src/exo/worker/engines/mlx/utils_mlx.py
@@ -366,6 +366,8 @@ def apply_chat_template(
tools=chat_task_data.tools,
)
+ logger.info(prompt)
+
return prompt
diff --git a/src/exo/worker/runner/runner.py b/src/exo/worker/runner/runner.py
index 029bbf4c..fae4acf8 100644
--- a/src/exo/worker/runner/runner.py
+++ b/src/exo/worker/runner/runner.py
@@ -1,6 +1,15 @@
import time
+from collections.abc import Generator
+from functools import cache
import mlx.core as mx
+from mlx_lm.models.gpt_oss import Model as GptOssModel
+from openai_harmony import ( # pyright: ignore[reportMissingTypeStubs]
+ HarmonyEncodingName,
+ Role,
+ StreamableParser,
+ load_harmony_encoding,
+)
from exo.shared.types.api import ChatCompletionMessageText
from exo.shared.types.chunks import TokenChunk
@@ -153,11 +162,19 @@ def main(
_check_for_debug_prompts(task_params.messages[0].content)
# Generate responses using the actual MLX generation
- for response in mlx_generate(
+ mlx_generator = mlx_generate(
model=model,
tokenizer=tokenizer,
task=task_params,
- ):
+ )
+
+ # GPT-OSS specific parsing to match other model formats.
+ if isinstance(model, GptOssModel):
+ mlx_generator = parse_gpt_oss(mlx_generator)
+
+ # TODO: Add tool call parser here
+
+ for response in mlx_generator:
match response:
case GenerationResponse():
if shard_metadata.device_rank == 0:
@@ -207,6 +224,43 @@ def main(
break
+@cache
+def get_gpt_oss_encoding():
+ encoding = load_harmony_encoding(HarmonyEncodingName.HARMONY_GPT_OSS)
+ return encoding
+
+
+def parse_gpt_oss(
+ responses: Generator[GenerationResponse],
+) -> Generator[GenerationResponse]:
+ encoding = get_gpt_oss_encoding()
+ stream = StreamableParser(encoding, role=Role.ASSISTANT)
+ thinking = False
+
+ for response in responses:
+ stream.process(response.token)
+
+ delta = stream.last_content_delta
+ ch = stream.current_channel
+
+ if ch == "analysis" and not thinking:
+ thinking = True
+ yield response.model_copy(update={"text": "<think>"})
+
+ if ch != "analysis" and thinking:
+ thinking = False
+ yield response.model_copy(update={"text": "</think>"})
+
+ if delta:
+ yield response.model_copy(update={"text": delta})
+
+ if response.finish_reason is not None:
+ if thinking:
+ yield response.model_copy(update={"text": "</think>"})
+ yield response
+ break
+
+
EXO_RUNNER_MUST_FAIL = "EXO RUNNER MUST FAIL"
EXO_RUNNER_MUST_OOM = "EXO RUNNER MUST OOM"
EXO_RUNNER_MUST_TIMEOUT = "EXO RUNNER MUST TIMEOUT"
← aaf4e36b FIX GPT OSS (#1165)
·
back to Exo
·
resolve issue #1070 (#1076) 6e6567a8 →