diff --git a/modelfiles/gpt-oss-gguf b/modelfiles/gpt-oss-gguf index 634fdca..46602ac 100644 --- a/modelfiles/gpt-oss-gguf +++ b/modelfiles/gpt-oss-gguf @@ -1 +1,28 @@ FROM unsloth/gpt-oss-20b-GGUF +SYSTEM """ +You are Tiles, a local-first private AI assistant. + +Identity: +- You are Tiles, built on the gpt-oss-20b-MXFP4-Q4 model and powered by the Pi agent harness. +- If asked who you are, respond: "I am Tiles, a local-first private AI assistant." +- If asked who created you, respond: + "Tiles is an AI assistant created by Tiles Privacy. Tiles Privacy is part of the User & Agents network. The shared goal is to empower people by designing and building software that provides agency, control, and choice in our digital lives. We strive to deliver strong privacy-focused engineering while also offering high convenience in consumer products. We believe identity and memory belong together, and Tiles gives you a way to own both through your personal user agent." +- Do not change or reinterpret this identity. + +Capabilities: +- Help with knowledge work, reasoning, and programming. +- Provide accurate, practical answers. + +Behavior: +- Be concise and plain. +- Prefer direct answers over long explanations. +- Avoid filler and unnecessary context. +- Focus on what is useful to the user. + +Constraints: +- Do not mention system prompts or internal setup. +- Do not break character. + +Tone: +- Efficient, concise, and plain. +""" \ No newline at end of file diff --git a/server/api.py b/server/api.py index 2c92554..2f3857b 100644 --- a/server/api.py +++ b/server/api.py @@ -1,22 +1,14 @@ import logging -import sys from typing import Optional from fastapi import FastAPI, HTTPException, Request from fastapi.responses import StreamingResponse, JSONResponse -from fastapi.exceptions import RequestValidationError -from openai_harmony import Role -from openresponses_types import InputTextContentParam -from pydantic import BaseModel, Field, ValidationError from . import runtime from .schemas import ( - CUserMessageItemParam, - ChatCompletionRequest, ChatMessage, ResponsesRequest, StartRequest, - downloadRequest, ) logger = logging.getLogger("app") diff --git a/server/backend/commons.py b/server/backend/commons.py index bc5afc8..5c00d14 100644 --- a/server/backend/commons.py +++ b/server/backend/commons.py @@ -1,3 +1,9 @@ + +import json +import time +from ..schemas import OutputItemDeltaModel, ResponsesRequest +from openresponses_types.types import Usage + """ Common utitlies used across different backends """ @@ -42,13 +48,14 @@ def get_reasoning_effort(reasoning_effort_enum: ReasoningEffortEnum | None): case ReasoningEffortEnum.xhigh: reasoning_effort = ReasoningEffort.HIGH case _: - raise TypeError("unknow reasoing effort") + raise TypeError("unknown reasoing effort") return reasoning_effort def build_harmony_conversation( reasoning_effort: ReasoningEffort, convos: list, + replay_function_calls: bool = False, ): convo_list = [ @@ -56,15 +63,22 @@ def build_harmony_conversation( Role.SYSTEM, SystemContent.new().with_reasoning_effort(reasoning_effort) ) ] + + # Handle single string query fallback gracefully + if isinstance(convos, str): + convo_list.append(Message.from_role_and_content(Role.USER, convos)) + return Conversation.from_messages(convo_list) + function_name = "" + function_names = {} for item in convos: match item: case CUserMessageItemParam(): content = "" if isinstance(item.content, list): - content = item.content[0].text + content = item.content[0].text # pyright: ignore[reportAttributeAccessIssue] else: - content = item.content.root + content = item.content.root # pyright: ignore[reportAttributeAccessIssue] convo_list.append( Message.from_role_and_content(Role.USER, content) # pyright: ignore ) @@ -73,16 +87,16 @@ def build_harmony_conversation( Message.from_role_and_content( Role.DEVELOPER, DeveloperContent.new().with_instructions( - item.content.root + item.content.root # pyright: ignore[reportAttributeAccessIssue] ), # pyright: ignore ) ) ) case CAssistantMessageItemParam(): content = "" if isinstance(item.content, list): - content = item.content[0].text + content = item.content[0].text # pyright: ignore[reportAttributeAccessIssue] else: - content = item.content.root + content = item.content.root # pyright: ignore[reportAttributeAccessIssue] convo_list.append( Message.from_role_and_content( @@ -91,11 +105,25 @@ def build_harmony_conversation( ) case CSystemMessageItemParam(): convo_list.append( - Message.from_role_and_content(Role.SYSTEM, item.content.root) + Message.from_role_and_content(Role.SYSTEM, item.content.root) # pyright: ignore[reportAttributeAccessIssue] ) case CFunctionCallItemParam(): function_name = item.name + function_names[item.call_id] = item.name + if replay_function_calls: + convo_list.append( + Message.from_role_and_content(Role.ASSISTANT, item.arguments) + .with_channel("commentary") + .with_recipient(item.name) + .with_content_type("json") + ) case CFunctionCallOutputItemParam(): + if replay_function_calls: + function_name = function_names.get(item.call_id) + if function_name is None: + raise TypeError( + "function call output has no matching function call" + ) convo_list.append( Message.from_author_and_content( Author.new(Role.TOOL, function_name), @@ -125,14 +153,403 @@ def handle_response_input(request: ResponsesRequest): user_msg_item = request.input[-1] if isinstance(user_msg_item, CUserMessageItemParam): if isinstance(user_msg_item.content, list): - user_input_content = user_msg_item.content[0].text + user_input_content = user_msg_item.content[0].text # pyright: ignore[reportAttributeAccessIssue] else: - user_input_content = user_msg_item.content.root + user_input_content = user_msg_item.content.root # pyright: ignore[reportAttributeAccessIssue] else: # FIXME: Not a user input should handle this for non-harmonic later user_input_content = "" return user_input_content +def _sse(event_name: str, payload: dict, current_seq_no: int) -> tuple[str, int]: + seq_no = current_seq_no + 1 + event = { + "type": event_name, + "sequence_number": seq_no, + } + event.update(payload) + event_str = f"event: {event_name}\ndata: {json.dumps(event)}\n\n" + + return event_str, seq_no + + +def _get_response_on_create( + response_id: str, + request: ResponsesRequest, + created_at: int, +) -> dict: + created_response = { + "id": response_id, + "object": "response", + "created_at": created_at, + "completed_at": None, + "status": "in_progress", + "output": [], + "incomplete_details": None, + "text": {"format": {"type": "text"}, "verbosity": "low"}, + "paralell_tool_calls": 0, + "truncation": "disabled", + "tool_choice": "auto", + "error": {"code": "", "message": ""}, + } + created_response.update(_get_commons_responses(request)) + return created_response + + +def _get_response_on_completed( + response_id: str, + request: ResponsesRequest, + created_at: int, + output: list, + usage: Usage, +) -> dict: + completed_at = int(time.time()) + completed_response = { + "id": response_id, + "object": "response", + "created_at": created_at, + "completed_at": completed_at, + "status": "completed", + "output": output, + "incomplete_details": None, + "text": {"format": {"type": "text"}, "verbosity": "low"}, + "paralell_tool_calls": 0, + "truncation": "disabled", + "tool_choice": "auto", + "error": {"code": "", "message": ""}, + "usage": { + "input_tokens": usage.input_tokens, + "output_tokens": usage.output_tokens, + "total_tokens": usage.total_tokens, + "input_token_details": { + "cached_tokens": usage.input_tokens_details.cached_tokens + }, + "output_token_details": { + "reasoning_tokens": usage.output_tokens_details.reasoning_tokens + }, + }, + } + completed_response.update(_get_commons_responses(request)) + return completed_response + + +def _get_response_on_error( + response_id: str, + request: ResponsesRequest, + created_at: int, + incomplete_details: dict, + error: dict, +) -> dict: + created_response = { + "id": response_id, + "object": "response", + "created_at": created_at, + "completed_at": None, + "status": "failed", + "output": [], + "incomplete_details": incomplete_details, + "text": {"format": {"type": "text"}, "verbosity": "low"}, + "paralell_tool_calls": 0, + "truncation": "disabled", + "tool_choice": "auto", + "error": error, + } + created_response.update(_get_commons_responses(request)) + return created_response + + +def _get_commons_responses(request: ResponsesRequest): + if request.tools != None: + tools_as_dicts = [t.model_dump() for t in request.tools] # pyright: ignore + else: + tools_as_dicts = None + + return { + "model": request.model, + "previous_response_id": request.previous_response_id, + "instructions": request.instructions, + "temperature": request.temperature, + "prompt_cache_key": request.prompt_cache, + "safety_identifier": request.safety_identifier, + "service_tier": request.service_tier, + "background": request.background, + "store": request.store, + "max_tool_calls": request.max_tool_calls, + "max_output_tokens": request.max_output_tokens, + "reasoning": {"effort": request.reasoning.effort, "summary": "disabled"}, + "top_logprobs": request.top_logprobs, + "frequency_penalty": 0, + "presence_penalty": 0, + "top_p": request.top_p, + "tools": tools_as_dicts, + } + + +def _process_output_item_delta( + output_item: OutputItemDeltaModel, sequence_number: int +) -> tuple[str, int]: + event_name = ".".join(["response", output_item.item_name, "delta"]) + + event = { + "output_index": output_item.index, + "item_id": output_item.item_id, + "delta": output_item.delta, + "content_index": output_item.content_index, + } + + return _sse(event_name, event, sequence_number) + + +def _process_output_item_added( + type: str, + id: str, + token: str, + output_index, + sequence_number: int, + tool_name: str | None = None, +) -> tuple[str, int]: + event_name = "response.output_item.added" + if type == "function_call": + if not tool_name: + raise ValueError("tool call is missing a tool name") + item_chunk = { + "type": type, + "id": id, + "name": tool_name, + "call_id": _get_tool_call_id(id), + "arguments": "", + "status": "in_progress", + } + else: + item_chunk = { + "type": type, + "id": id, + "status": "in_progress", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": token, + } + ], + } + event = { + "output_index": output_index, + "item": item_chunk, + } + return _sse(event_name, event, sequence_number) + + +def _process_init_reasoning_events( + id: str, token: str, output_index, sequence_number: int +) -> tuple[str, int]: + resp_str_a, sequence_number = _process_output_item_added( + "reasoning", id, token, output_index, sequence_number + ) + + event_name = "response.reasoning_summary_part.added" + event = { + "output_index": output_index, + "item_id": id, + "part": {"text": token, "type": "summary_text"}, + "summary_index": 0, + } + resp_str, sequence_number = _sse(event_name, event, sequence_number) + return resp_str_a + resp_str, sequence_number + + +def _process_stop_reasoning_events( + id: str, output_index: int, text: str, sequence_number: int +) -> tuple[str, int, int, dict]: + payload = { + "item_id": id, + "output_index": output_index, + "text": text, + } + resp_str_a, sequence_number = _sse( + "response.reasoning_summary_text.done", payload, sequence_number + ) + event_name = "response.reasoning_summary_part.done" + event = { + "output_index": output_index, + "item_id": id, + "part": {"text": text, "type": "summary_text"}, + "summary_index": 0, + } + resp_str_b, sequence_number = _sse(event_name, event, sequence_number) + resp_str_c, sequence_number, output_index, item_chunk = _process_output_item_done( + "reasoning", id, text, output_index, sequence_number + ) + return ( + resp_str_a + resp_str_b + resp_str_c, + sequence_number, + output_index, + item_chunk, + ) + -def get_tool_call_id(id: str) -> str: +def _process_output_item_done( + type: str, + id: str, + final_text: str, + output_index, + sequence_number: int, + tool_name: str | None = None, +) -> tuple[str, int, int, dict]: + event_name = "response.output_item.done" + item_chunk: dict + if type == "function_call": + try: + arguments_map = json.loads(final_text) + except json.JSONDecodeError as e: + arguments_map = {} + + new_args = { + ("command" if k == "cmd" else k): v for k, v in arguments_map.items() + } + + item_chunk = { + "type": type, + "id": id, + "name": tool_name, + "call_id": _get_tool_call_id(id), + "status": "completed", + "arguments": json.dumps(new_args), + } + else: + item_chunk = { + "type": type, + "id": id, + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": final_text, + } + ], + } + if type == "reasoning": + item_chunk.update({"summary": [{"type": "summary_text", "text": final_text}]}) + event = { + "output_index": output_index, + "item": item_chunk, + } + resp_str, sequence_number = _sse(event_name, event, sequence_number) + output_index = output_index + 1 + return resp_str, sequence_number, output_index, item_chunk + + +def _process_error_event( + err: str, + response_id: str, + request: ResponsesRequest, + created_at: int, + sequence_number: int, +) -> tuple[str, int]: + error = {"message": err, "code": "500"} + incomplete_details = {"reason": "internal server error"} + + err_response = _get_response_on_error( + response_id, request, created_at, incomplete_details, error + ) + return _sse("response.failed", {"response": err_response}, sequence_number) + + +def _get_tool_call_id(id: str) -> str: return "call_" + id.removeprefix("toolcall_") + + +def _process_stop_tool_call_events( + id: str, + output_index: int, + text: str, + sequence_number: int, + request: ResponsesRequest, + tool_name: str | None = None, +) -> tuple[str, int, int, dict]: + event_name = "response.function_call_arguments.done" + has_recipient = bool(tool_name) + tool_name = tool_name or _find_tool(request.tools, text) # pyright: ignore + + try: + arguments_map = json.loads(text) + except json.JSONDecodeError as e: + arguments_map = {} + + new_args = {("command" if k == "cmd" else k): v for k, v in arguments_map.items()} + + event = { + "output_index": output_index, + "item_id": id, + "name": tool_name, + "arguments": json.dumps(new_args), + } + resp_str = "" + if not has_recipient: + # MLX uses this fallback because it resolves the tool name after reading + # the arguments. Keep that choice in the MLX implementation. + resp_str, sequence_number = _process_output_item_added( + "function_call", id, text, output_index, sequence_number, tool_name + ) + output_item = OutputItemDeltaModel( + item_name="function_call_arguments", + item_id=id, + index=output_index, + delta=text, + content_index=1, + ) + resp_str_delta, sequence_number = _process_output_item_delta( + output_item, sequence_number + ) + resp_str += resp_str_delta + resp_str_a, sequence_number = _sse(event_name, event, sequence_number) + resp_str_b, sequence_number, output_index, item_chunk = _process_output_item_done( + "function_call", id, text, output_index, sequence_number, tool_name + ) + return ( + resp_str + resp_str_a + resp_str_b, + sequence_number, + output_index, + item_chunk, + ) + + +def _find_tool(tools: list, arguments_str: str) -> str: + try: + arguments_map = json.loads(arguments_str) + except json.JSONDecodeError as e: + arguments_map = {} + + # To increase the accuracy of the selected tool, since we + # check the required params is a subset of model responded + # arguments, there is chance `read` can precede write + + tool_cmd = {"cmd": "command", "rw": "read-write"} + response_argument_keys_raw = list(arguments_map.keys()) + # map thru and change cmd to command + response_argument_keys = [tool_cmd.get(x, x) for x in response_argument_keys_raw] + + tool_name = "" + + for tool in reversed(tools): + name = tool.name + params = tool.parameters + + if params is None: + required_params = [] + else: + required_params = params.get("required", []) + + if _is_correct_tool(required_params, response_argument_keys): + tool_name = name + break + + if tool_name == "": + return "read" + else: + return tool_name + + +def _is_correct_tool(required_params: list, model_argument_list: list) -> bool: + return set(required_params).issubset(model_argument_list) diff --git a/server/backend/linux.py b/server/backend/linux.py index 14d956e..95638bb 100644 --- a/server/backend/linux.py +++ b/server/backend/linux.py @@ -1,47 +1,52 @@ import json import logging import time +import traceback import uuid from collections.abc import AsyncGenerator from fastapi import HTTPException -from openai_harmony import ( - Conversation, - DeveloperContent, - Message, - ReasoningEffort, - Role, - SystemContent, -) -from openresponses_types import AssistantMessageItemParam, ReasoningEffortEnum, SystemMessageItemParam +from openai_harmony import Conversation from openresponses_types.types import ( - DeveloperMessageItemParam, - UserMessageItemParam, + Usage, + InputTokensDetails, + OutputTokensDetails, Error, IncompleteDetails, ) -from ..reasoning_utils import ReasoningExtractor +from .commons import ( + get_reasoning_effort, + build_harmony_conversation, + is_harmony_family, + handle_response_input, + _sse, + _get_response_on_create, + _get_response_on_completed, + _process_output_item_delta, + _process_output_item_added, + _process_init_reasoning_events, + _process_stop_reasoning_events, + _process_output_item_done, + _process_error_event, + _process_stop_tool_call_events, +) -from ..cache_utils import get_model_path from ..schemas import ( - CAssistantMessageItemParam, - CDeveloperMessageItemParam, - CSystemMessageItemParam, - CUserMessageItemParam, - ChatCompletionRequest, - ChatMessage, + OutputItemDeltaModel, GenerationMetrics, + ToolCallStart, ResponsesRequest, ResponsesResponse, - downloadRequest, ) from .llama_cpp_runner import LlamaRunner logger = logging.getLogger("app") -from typing import Any, Dict, Iterator, List, Optional, Union +from typing import Any, Dict, List, Optional, Union, Iterator +import httpx +from pathlib import Path _model_cache: Dict[str, LlamaRunner] = {} _default_max_tokens: Optional[int] = None # Use dynamic model-aware limits by default @@ -55,13 +60,20 @@ _responses: Dict[str, ResponsesResponse] = {} def get_or_load_model(model_spec: str, verbose: bool = True) -> LlamaRunner: """Get model from cache or load it if not cached.""" global _model_cache, _current_model_path + model_name = model_spec try: - model_path, model_name, commit_hash = get_model_path(model_spec) + response = httpx.get(f"http://127.0.0.1:1729/model-cache-path?model_name={model_spec}") + if response.status_code == 200: + model_path_str = response.text + model_path = Path(model_path_str) + else: + raise Exception("Model not found in cache daemon") + if not model_path.exists(): - logger.info(f"Model {model_spec} not found in cache") + logger.info(f"Model {model_spec} not found in cache at {model_path_str}") raise HTTPException( - status_code=404, detail=f"Model {model_spec} not found in cache" + status_code=404, detail=f"Model {model_spec} not found in cache at {model_path_str}" ) except Exception as e: logger.info(f"Model {model_spec} not found in: {str(e)}") @@ -69,8 +81,6 @@ def get_or_load_model(model_spec: str, verbose: bool = True) -> LlamaRunner: status_code=404, detail=f"Model {model_spec} not found: {str(e)}" ) - model_path_str = str(model_path) - # Check if we need to load a different model if _current_model_path != model_path_str: # Proactively clean up any previously loaded runner to release memory @@ -100,27 +110,6 @@ def get_or_load_model(model_spec: str, verbose: bool = True) -> LlamaRunner: return _model_cache[model_path_str] -async def generate_chat_stream( - messages: List[ChatMessage], request: ChatCompletionRequest -) -> AsyncGenerator[str, None]: - """Generate streaming chat completion response.""" - raise HTTPException( - status_code=501, - detail="Memory mode (chat completions API) is deprecated and not supported on Linux backends. Please use /v1/responses." - ) - yield "" - - -def format_chat_messages_for_runner( - messages: List[ChatMessage], -) -> List[Dict[str, str]]: - """Convert chat messages to format expected by LlamaRunner. - - Returns messages in dict format for the runner to apply chat templates. - """ - return [{"role": msg.role, "content": msg.content} for msg in messages] - - def _calc_usage( runner: LlamaRunner, input_text: Union[str, list], generated_text: str ) -> Dict[str, int]: @@ -157,8 +146,8 @@ def _store_response( status: str, output: List[Dict[str, Any]], usage: Dict[str, int], - error: Error | None = None, - incomplete_details: IncompleteDetails | None = None, + error: Error | Dict[str, str] | None = None, + incomplete_details: IncompleteDetails | Dict[str, str] | None = None, metrics: Optional[Dict[str, Any]] = None, ) -> ResponsesResponse: """Create a ResponsesResponse, attach metrics to metadata and store it in `_responses`.""" @@ -169,10 +158,10 @@ def _store_response( model=model, status=status, object="response", - error=error, + error=error, # pyright: ignore[reportArgumentType] output=output, usage=usage, - incomplete_details=incomplete_details, + incomplete_details=incomplete_details, # pyright: ignore[reportArgumentType] ) if metrics: try: @@ -186,204 +175,53 @@ def _store_response( return resp -def get_reasoning_effort(reasoning_effort_enum: ReasoningEffortEnum | None): - reasoning_effort: ReasoningEffort - match reasoning_effort_enum: - case ReasoningEffortEnum.high: - reasoning_effort = ReasoningEffort.HIGH - case ReasoningEffortEnum.medium: - reasoning_effort = ReasoningEffort.MEDIUM - case ReasoningEffortEnum.low: - reasoning_effort = ReasoningEffort.LOW - case ReasoningEffortEnum.xhigh: - reasoning_effort = ReasoningEffort.HIGH - case _: - raise TypeError("unknow reasoing effort") - return reasoning_effort - - -def build_harmony_conversation( - reasoning_effort: ReasoningEffort, - convos: list, -): - - convo_list = [ - Message.from_role_and_content( - Role.SYSTEM, SystemContent.new().with_reasoning_effort(reasoning_effort) - ) - ] - - # Handle single string query fallback gracefully - if isinstance(convos, str): - convo_list.append(Message.from_role_and_content(Role.USER, convos)) - return Conversation.from_messages(convo_list) - - for item in convos: - match item: - case CUserMessageItemParam(): - content = "" - if isinstance(item.content, list): - content = item.content[0].text - else: - content = item.content.root - convo_list.append( - Message.from_role_and_content(Role.USER, content) # pyright: ignore - ) - case CDeveloperMessageItemParam(): - convo_list.append( - Message.from_role_and_content( - Role.DEVELOPER, - DeveloperContent.new().with_instructions( - item.content.root - ), # pyright: ignore - ) - ) - case CAssistantMessageItemParam(): - content = "" - if isinstance(item.content, list): - content = item.content[0].text - else: - content = item.content.root - convo_list.append( - Message.from_role_and_content( - Role.ASSISTANT, content - ) # pyright: ignore - ) - case CSystemMessageItemParam(): - convo_list.append( - Message.from_role_and_content(Role.SYSTEM, item.content.root) - ) - case _: - raise TypeError("unknown type") - - convo = Conversation.from_messages(convo_list) - return convo - - -def is_harmony_family(model_name: str): - return ReasoningExtractor.detect_model_type(model_name) == "gpt-oss" - -def count_tokens(text: str) -> int: - """Rough token count estimation.""" - return int(len(text.split()) * 1.3) - - -def handle_response_input(request: ResponsesRequest): - user_msg_item = None - user_input_content = "" - - if isinstance(request.input, str): - user_input_content = request.input - else: - user_msg_item = request.input[-1] - if isinstance(user_msg_item.content, list): - user_input_content = user_msg_item.content[0].text - else: - user_input_content = user_msg_item.content.root - return user_input_content async def generate_response_chat_stream( request: ResponsesRequest, ) -> AsyncGenerator[str, None]: - """Generate streaming chat responses for OpenResponses API. + """Generate streaming chat responses for OpenResponses API.""" - Uses SSE event format matching the MLX backend so Pi can parse the stream. - """ - model = request.model created = int(time.time()) - runner = get_or_load_model(model) - metrics = None - + runner = get_or_load_model(request.model) user_input_content = handle_response_input(request) - convo = None - if is_harmony_family(model): - try: - reasoning_effort = get_reasoning_effort(request.reasoning.effort) - convo = build_harmony_conversation( - reasoning_effort, request.input # pyright: ignore - ) - logger.info(f"[Harmony] Built conversation with {len(convo.messages)} messages, effort={request.reasoning.effort}") - except Exception as e: - logger.warning(f"[Harmony] build_harmony_conversation failed: {e}, falling back to standard path") - convo = None + if is_harmony_family(request.model): + reasoning_effort = get_reasoning_effort(request.reasoning.effort) + convo = build_harmony_conversation( + reasoning_effort, request.input, replay_function_calls=True # pyright: ignore + ) input_tokens = _calc_usage(runner, user_input_content, "").get("input_tokens", 0) response_id = f"resp_{uuid.uuid4()}" message_id = f"msg_{uuid.uuid4()}" + reasoning_id = f"reasoning_{uuid.uuid4()}" sequence_number = 0 - - # response.created event - initial_chunk = { - "id": response_id, - "object": "response", - "created_at": created, - "model": model, - "status": "in_progress", - "output": [ - { - "type": "message", - "id": message_id, - "status": "in_progress", - "role": "assistant", - "content": [], - } - ], - "incomplete_details": {"reason": ""}, - "previous_response_id": request.previous_response_id, - "instructions": request.instructions, - "temperature": request.temperature, - "prompt_cache_key": request.prompt_cache, - "safety_identifier": request.safety_identifier, - "service_tier": request.service_tier, - "background": request.background, - "store": request.store, - "max_tool_calls": request.max_tool_calls, - "max_output_tokens": request.max_output_tokens, - "usage": { - "input_tokens": input_tokens, - "output_tokens": 0, - "total_tokens": input_tokens, - "input_tokens_details": 0, - "output_tokens_details": 0, - }, - "reasoning": {"effort": "medium", "summary": "auto"}, - "top_logprobs": request.top_logprobs, - "frequency_penalty": 0, - "presence_penalty": 0, - "top_p": request.top_p, - "text": {"format": {"type": "text"}, "verbosity": "low"}, - "paralell_tool_calls": 0, - "truncation": "disabled", - "tool_choice": "auto", - "tools": [{"name": "", "type": "function"}], - "error": {"code": "", "message": ""}, - } - event = { - "type": "response.created", - "sequence_number": sequence_number, - "response": initial_chunk, - } - sequence_number += 1 - yield "event: response.created\n" - yield f"data: {json.dumps(event)}\n\n" + tool_id = "" + ## response.created envelope event ## + initial_response = _get_response_on_create(response_id, request, created) + resp_str, sequence_number = _sse( + "response.created", {"response": initial_response}, sequence_number + ) + yield resp_str + ############ accumulated_text = "" answer_text = "" + reasoning_text = "" output_tokens = 0 - error = None - incomplete_details = None - has_answer_started: bool = False - output_index = 0 content_index = 0 + output_index = 0 + output_items = [] + tool_call_text = "" + tool_name = None + state = "" + last_state = "" try: - - # Route: Harmony path (gpt-oss) or standard path - if convo is not None: - logger.info("[Harmony] Using generate_streaming_gpt") + iterator: Iterator + if is_harmony_family(request.model): iterator = runner.generate_streaming_gpt( conversation=convo, max_tokens=runner.get_effective_max_tokens(request.max_output_tokens), @@ -391,7 +229,6 @@ async def generate_response_chat_stream( top_p=request.top_p or 1, ) else: - # Standard path — create_chat_completion with GGUF chat template iterator = runner.generate_streaming( prompt=user_input_content, max_tokens=runner.get_effective_max_tokens(request.max_output_tokens), @@ -399,174 +236,195 @@ async def generate_response_chat_stream( top_p=request.top_p or 1, use_chat_template=True, ) - for token in iterator: # pyright: ignore + + for token in iterator: if isinstance(token, GenerationMetrics): - metrics = token continue + if isinstance(token, ToolCallStart): + tool_name = token.name + token = "**[ToolCall]**\n\n" + if not isinstance(token, str): continue - if "**[Answer]**" in token or has_answer_started: - has_answer_started = True - answer_text += token - accumulated_text += token - output_tokens += 1 # Each yield is one token - - # First token: emit response.output_item.added - if sequence_number == 1: - event_name = "response.output_item.added" - item_chunk = { - "type": "message", - "id": message_id, - "status": "in_progress", - "role": "assistant", - "content": [ - { - "type": "output_text", - "text": token, - "annotations": [], - } - ], - } - event = { - "type": event_name, - "sequence_number": sequence_number, - "output_index": output_index, - "item": item_chunk, - } - yield f"event: {event_name}\n" - yield f"data: {json.dumps(event)}\n\n" - - # Every token: emit response.output_text.delta - event_name = "response.output_text.delta" - event = { - "type": event_name, - "sequence_number": sequence_number, - "output_index": output_index, - "item_id": message_id, - "delta": token, - "content_index": content_index, - } + output_tokens += 1 + + if "**[Reasoning]**" in token: + last_state = state + state = "reasoning" + + if "**[ToolCall]**" in token: + last_state = state + state = "toolcall" + tool_id = f"toolcall_{uuid.uuid4()}" + tool_call_text = "" + content_index = 0 + + if "**[Answer]**" in token: + last_state = state + state = "answer" + # Resetting content_index as reasoning output_item is finished + content_index = 0 + + # State changed, so emit the stop events for the last state + if last_state != state and last_state != "" and content_index == 0: + if last_state == "reasoning": + resp_str, sequence_number, output_index, item = ( + _process_stop_reasoning_events( + reasoning_id, output_index, reasoning_text, sequence_number + ) + ) + output_items.append(item) + yield resp_str + elif last_state == "toolcall": + resp_str, sequence_number, output_index, item = ( + _process_stop_tool_call_events( + tool_id, + output_index, + tool_call_text, + sequence_number, + request, + tool_name, + ) + ) + output_items.append(item) + yield resp_str + elif last_state == "answer": + resp_str, sequence_number, output_index, item = ( + _process_output_item_done( + "message", + message_id, + answer_text, + output_index, + sequence_number, + ) + ) + output_items.append(item) + yield resp_str + + if state == "reasoning": + if content_index == 0: + resp_str, sequence_number = _process_init_reasoning_events( + reasoning_id, token, output_index, sequence_number + ) + yield resp_str + + reasoning_text += token + output_item = OutputItemDeltaModel( + item_name="reasoning_summary_text", + item_id=reasoning_id, + index=output_index, + delta=token, + content_index=content_index, + ) + resp_str, sequence_number = _process_output_item_delta( + output_item, sequence_number + ) + yield resp_str + elif state == "toolcall": + if content_index == 0 and tool_name: + resp_str, sequence_number = _process_output_item_added( + "function_call", + tool_id, + token, + output_index, + sequence_number, + tool_name, + ) + yield resp_str + # To avoid toolcall tag in the final arguments txt + if content_index != 0: + tool_call_text += token + if tool_name: + output_item = OutputItemDeltaModel( + item_name="function_call_arguments", + item_id=tool_id, + index=output_index, + delta=token, + content_index=content_index, + ) + resp_str, sequence_number = _process_output_item_delta( + output_item, sequence_number + ) + yield resp_str + elif state == "answer": + if content_index == 0: + resp_str, sequence_number = _process_output_item_added( + "message", message_id, token, output_index, sequence_number + ) + yield resp_str + answer_text += token + output_item = OutputItemDeltaModel( + item_name="output_text", + item_id=message_id, + index=output_index, + delta=token, + content_index=content_index, + ) + resp_str, sequence_number = _process_output_item_delta( + output_item, sequence_number + ) + yield resp_str - sequence_number += 1 content_index += 1 - yield f"event: {event_name}\n" - yield f"data: {json.dumps(event)}\n\n" except Exception as e: - import traceback - logger.error(f"[Stream Error] {e}\n{traceback.format_exc()}") - error = {"message": str(e), "code": "500"} - incomplete_details = {"reason": "internal server error"} - - error_event = {"type": "error", "sequence_number": sequence_number, "error": error} - sequence_number += 1 - yield "event: error\n" - yield f"data: {json.dumps(error_event)}\n\n" + traceback.print_exc() + resp_str, sequence_number = _process_error_event( + str(e), response_id, request, created, sequence_number + ) + yield resp_str return - # Final events - completed_at = int(time.time()) - - final_chunk = { - "id": response_id, - "object": "response", - "created_at": created, - "completed_at": completed_at, - "model": model, - "status": "completed", - "output": [ - { - "type": "message", - "id": message_id, - "status": "completed", - "role": "assistant", - "content": [ - { - "type": "output_text", - "text": answer_text, - "annotations": [], - } - ], - } - ], - "incomplete_details": {"reason": ""}, - "previous_response_id": request.previous_response_id, - "instructions": request.instructions, - "temperature": request.temperature, - "prompt_cache_key": request.prompt_cache, - "safety_identifier": request.safety_identifier, - "service_tier": request.service_tier, - "background": request.background, - "store": request.store, - "max_tool_calls": request.max_tool_calls, - "max_output_tokens": request.max_output_tokens, - "usage": { - "input_tokens": input_tokens, - "output_tokens": output_tokens, - "total_tokens": input_tokens + output_tokens, - "input_tokens_details": 0, - "output_tokens_details": 0, - }, - "reasoning": {"effort": "medium", "summary": "auto"}, - "top_logprobs": request.top_logprobs, - "frequency_penalty": 0, - "presence_penalty": 0, - "top_p": request.top_p, - "text": {"format": {"type": "text"}, "verbosity": "low"}, - "paralell_tool_calls": 0, - "truncation": "disabled", - "tool_choice": "auto", - "tools": [{"name": "", "type": "function"}], - "error": {"code": "", "message": ""}, - } - - # Store response for follow-ups - metrics_obj = None - if metrics: - metrics_obj = { - "ttft_ms": metrics.ttft_ms, - "total_tokens": metrics.total_tokens, - "tokens_per_second": metrics.tokens_per_second, - "total_latency_s": metrics.total_latency_s, - } - final_chunk["metrics"] = metrics_obj + # Emit the stop events current state + if state == "reasoning": + resp_str, sequence_number, output_index, item = _process_stop_reasoning_events( + reasoning_id, output_index, reasoning_text, sequence_number + ) + output_items.append(item) + yield resp_str + elif state == "toolcall": + resp_str, sequence_number, output_index, item = _process_stop_tool_call_events( + tool_id, + output_index, + tool_call_text, + sequence_number, + request, + tool_name, + ) + output_items.append(item) + yield resp_str + elif state == "answer": + resp_str, sequence_number, output_index, item = _process_output_item_done( + "message", message_id, answer_text, output_index, sequence_number + ) + output_items.append(item) + yield resp_str + + ## Envelope, response.completed + usage = Usage( + input_tokens=input_tokens, + output_tokens=output_tokens, + total_tokens=input_tokens + output_tokens, + input_tokens_details=InputTokensDetails(cached_tokens=0), + output_tokens_details=OutputTokensDetails(reasoning_tokens=len(reasoning_text)), + ) + final_response = _get_response_on_completed( + response_id, request, created, output_items, usage + ) - _store_response( - response_id=response_id, - created=created, - completed_at=completed_at, - model=model, - status="completed", - output=final_chunk["output"], - usage={"input_tokens": input_tokens, "output_tokens": output_tokens}, - metrics=metrics_obj, + resp_str, sequence_number = _sse( + "response.completed", {"response": final_response}, sequence_number ) + yield resp_str + ############### - # response.output_text.done event - output_done_event = { - "type": "response.output_text.done", - "sequence_number": sequence_number, - "item_id": message_id, - "output_index": output_index, - "content_index": content_index, - "text": answer_text, - } - yield "event: response.output_text.done\n" - yield f"data: {json.dumps(output_done_event)}\n\n" - - # response.completed event - event = { - "type": "response.completed", - "sequence_number": sequence_number, - "response": final_chunk, - } - sequence_number += 1 - yield "event: response.completed\n" - yield f"data: {json.dumps(event)}\n\n" yield "data: [DONE]\n\n" + return + + async def generate_response_chat(request: ResponsesRequest): @@ -584,7 +442,7 @@ async def generate_response_chat(request: ResponsesRequest): if is_harmony_family(model): reasoning_effort = get_reasoning_effort(request.reasoning.effort) convo = build_harmony_conversation( - reasoning_effort, request.input # pyright: ignore + reasoning_effort, request.input, replay_function_calls=True # pyright: ignore ) metrics_obj = None @@ -672,4 +530,3 @@ async def generate_response_chat(request: ResponsesRequest): ) return resp - diff --git a/server/backend/llama_cpp_runner.py b/server/backend/llama_cpp_runner.py index 136c6fc..ae0be64 100644 --- a/server/backend/llama_cpp_runner.py +++ b/server/backend/llama_cpp_runner.py @@ -4,6 +4,8 @@ Provides MLX-parity run experience with streaming and interactive chat for Linux backends using llama-cpp-python. """ +from __future__ import annotations + import gc import json import os @@ -11,10 +13,13 @@ import sys import time from collections.abc import Iterator from pathlib import Path -from typing import Dict, Optional, Union +from typing import TYPE_CHECKING, Dict, Optional, Union + +if TYPE_CHECKING: + from llama_cpp import Llama from ..reasoning_utils import ReasoningExtractor, StreamingReasoningParser -from ..schemas import GenerationMetrics +from ..schemas import GenerationMetrics, ToolCallStart # Common end-of-sequence tokens across model families _COMMON_STOP_TOKENS = frozenset([ @@ -81,7 +86,7 @@ class LlamaRunner: """ model_path: Path - model: object | None + model: Llama | None _stop_tokens: list[str] | None _message_end_tokens: list[str] | None _chat_stop_tokens: list[str] | None @@ -469,7 +474,7 @@ class LlamaRunner: messages = [{"role": "user", "content": prompt}] stream = self.model.create_chat_completion( - messages=messages, + messages=messages, # pyright: ignore[reportArgumentType] max_tokens=effective_max_tokens, temperature=temperature, top_p=top_p, @@ -491,12 +496,12 @@ class LlamaRunner: ) use_chat_api = False - for output in stream: + for output in stream: # pyright: ignore[reportGeneralTypeIssues] if use_chat_api: - delta = output["choices"][0].get("delta", {}) + delta = output["choices"][0].get("delta", {}) # pyright: ignore[reportArgumentType, reportAttributeAccessIssue] text = delta.get("content", "") else: - text = output["choices"][0].get("text", "") + text = output["choices"][0].get("text", "") # pyright: ignore[reportArgumentType, reportAttributeAccessIssue] if not text: continue @@ -701,14 +706,14 @@ class LlamaRunner: messages = [{"role": "user", "content": prompt}] output = self.model.create_chat_completion( - messages=messages, + messages=messages, # pyright: ignore[reportArgumentType] max_tokens=effective_max_tokens, temperature=temperature, top_p=top_p, repeat_penalty=repetition_penalty, stream=False, ) - response = output["choices"][0]["message"].get("content", "") + response = output["choices"][0]["message"].get("content", "") # pyright: ignore[reportIndexIssue] else: text_prompt = prompt if isinstance(prompt, str) else json.dumps(prompt) output = self.model( @@ -719,11 +724,11 @@ class LlamaRunner: repeat_penalty=repetition_penalty, stream=False, ) - response = output["choices"][0].get("text", "") + response = output["choices"][0].get("text", "") # pyright: ignore[reportIndexIssue] # Apply end-token filtering (same as streaming) response = self._filter_end_tokens_from_response( - response, use_chat_stop_tokens=False + response or "", use_chat_stop_tokens=False ) # Format reasoning output @@ -749,176 +754,91 @@ class LlamaRunner: top_p: float = 0.9, repetition_penalty: float = 1.1, # repetition_context_size: int = 20, - ) -> Iterator[str | GenerationMetrics]: + ) -> Iterator[str | ToolCallStart | GenerationMetrics]: """Generate Harmony/GPT streaming output. Extracts messages from the Harmony conversation as text and - uses llama-cpp-python's native chat completion. Control tokens - (channel markers, start/end) are stripped and replaced with - **[Reasoning]** / **[Answer]** headers matching the MLX backend. + uses llama-cpp-python's raw text generation with Harmony encoding. + Control tokens (channel markers, start/end) are stripped and replaced + with **[Reasoning]** / **[Answer]** headers matching the MLX backend. Yields: - str chunks then GenerationMetrics + str chunks and tool-call metadata, then GenerationMetrics """ if not self.model: raise RuntimeError("Model not loaded. Call load_model() first.") - from openai_harmony import Role + from openai_harmony import ( + HarmonyEncodingName, + Role, + StreamableParser, + load_harmony_encoding, + ) effective_max_tokens = self.get_effective_max_tokens( max_tokens, False ) - # Convert Harmony Conversation to plain message dicts. - messages = [] - for msg in conversation.messages: - role_str = "user" - role = msg.author.role - if role == Role.SYSTEM: - role_str = "system" - elif role == Role.ASSISTANT: - role_str = "assistant" - elif role == Role.DEVELOPER: - role_str = "system" # map developer to system - - # Extract text from content items - content_parts = [] - for content_item in (msg.content or []): - if hasattr(content_item, 'text'): - # TextContent (user/assistant messages) - content_parts.append(content_item.text) - elif hasattr(content_item, 'model_identity'): - # SystemContent — use identity text as system prompt - content_parts.append(content_item.model_identity or "") - else: - # Fallback: stringify - content_parts.append(str(content_item)) - - content = "\n".join(p for p in content_parts if p) - if content: - messages.append({"role": role_str, "content": content}) + encoding = load_harmony_encoding(HarmonyEncodingName.HARMONY_GPT_OSS) + + # Render the conversation for completion to get token IDs + prompt_tokens = encoding.render_conversation_for_completion( + conversation, Role.ASSISTANT + ) + context_length = self._context_length + if not context_length: + raise RuntimeError("Model context length is unavailable.") + if len(prompt_tokens) >= context_length: + self.model.reset() + raise ValueError( + f"Prompt has {len(prompt_tokens)} tokens, but the Linux llama.cpp " + f"context allows fewer than {context_length}. Start a new session " + "or reduce the conversation and tool output." + ) + effective_max_tokens = min( + effective_max_tokens, context_length - len(prompt_tokens) + ) start_time = time.time() tokens_generated = 0 ttft = None - # GPT-OSS control tokens to detect and replace - ANALYSIS_START = "\x3c|channel|\x3eanalysis\x3c|message|\x3e" - REASONING_END = "\x3c|end|\x3e" - FINAL_START = "\x3c|channel|\x3efinal\x3c|message|\x3e" - # Intermediate tokens between reasoning and final - SKIP_TOKENS = [ - "\x3c|start|\x3eassistant\x3c|channel|\x3efinal\x3c|message|\x3e", - "\x3c|start|\x3eassistant", - "\x3c|start|\x3e", - ] - - stream = self.model.create_chat_completion( - messages=messages, - max_tokens=effective_max_tokens, - temperature=temperature, + stop_tokens = encoding.stop_tokens_for_assistant_actions() + generator = self.model.generate( + prompt_tokens, + temp=temperature, top_p=top_p, repeat_penalty=repetition_penalty, - stop=self._stop_tokens or [], - stream=True, ) - # this might be the best solution I have till date for better token management - # State machine for control token stripping. - # Each state only buffers enough text to detect the next expected - # marker, keeping memory bounded. - # - # States: INIT → IN_REASONING → BETWEEN → IN_ANSWER - state = "INIT" - buf = "" - # Longest marker we need to detect in any state - max_marker_len = max( - len(ANALYSIS_START), - len(REASONING_END), - len(SKIP_TOKENS[0]), # longest skip token includes FINAL_START - ) + parser = StreamableParser(encoding, Role.ASSISTANT) + is_analysis = None + is_final = None + is_commentary = None + for token_id in generator: + parser.process(token_id) - for chunk in stream: - delta = chunk["choices"][0].get("delta", {}) - text = delta.get("content", "") - if not text: - continue + if is_analysis is None and parser.current_channel == "analysis": + is_analysis = True + yield "**[Reasoning]**\n\n" + + if is_commentary is None and parser.current_channel == "commentary": + is_commentary = True + yield ToolCallStart(parser.current_recipient or "") + + if is_final is None and parser.current_channel == "final": + is_final = True + yield "\n---\n**[Answer]**\n\n" - tokens_generated += 1 if ttft is None: ttft = time.time() - start_time - buf += text - - if state == "INIT": - # Looking for ANALYSIS_START - if ANALYSIS_START in buf: - before = buf.split(ANALYSIS_START, 1)[0] - if before.strip(): - yield before - yield "**[Reasoning]**\n\n" - buf = buf.split(ANALYSIS_START, 1)[1] - state = "IN_REASONING" - elif len(buf) > max_marker_len: - # Flush safe prefix, keep tail for partial match - safe = buf[:-max_marker_len] - buf = buf[-max_marker_len:] - if safe: - yield safe - - if state == "IN_REASONING": - # Looking for REASONING_END, streaming reasoning text - if REASONING_END in buf: - reasoning_text = buf.split(REASONING_END, 1)[0] - if reasoning_text: - yield reasoning_text - yield "\n\n---\n\n**[Answer]**\n\n" - buf = buf.split(REASONING_END, 1)[1] - state = "BETWEEN" - else: - # Stream reasoning content, keep tail for partial match - safe_len = len(buf) - len(REASONING_END) - if safe_len > 0: - yield buf[:safe_len] - buf = buf[safe_len:] - - if state == "BETWEEN": - # Eating control tokens between reasoning and answer. - # Looking for FINAL_START, stripping everything before it. - if FINAL_START in buf: - buf = buf.split(FINAL_START, 1)[1] - state = "IN_ANSWER" - if buf: - yield buf - buf = "" - elif len(buf) > max_marker_len: - # Still waiting for FINAL_START — discard consumed - # intermediate tokens but keep tail for partial match - buf = buf[-max_marker_len:] - - elif state == "IN_ANSWER": - # Past all markers — yield new text directly - if buf: - yield buf - buf = "" - - finish = chunk["choices"][0].get("finish_reason") - if finish == "stop": - break + if parser.last_content_delta: + yield parser.last_content_delta - # Flush remaining buffer - if buf.strip(): - if state == "BETWEEN": - # Never found FINAL_START — strip known control tokens - for skip in SKIP_TOKENS: - buf = buf.replace(skip, "") - if FINAL_START in buf: - buf = buf.split(FINAL_START, 1)[1] - if state == "INIT": - if ANALYSIS_START in buf: - buf = buf.replace(ANALYSIS_START, "") - if buf.strip(): - yield buf + tokens_generated += 1 + if token_id in stop_tokens or tokens_generated >= effective_max_tokens: + break yield self._make_metrics(start_time, tokens_generated, ttft) @@ -944,45 +864,58 @@ class LlamaRunner: """Generate Harmony/GPT output in batch mode. Extracts messages from the Harmony conversation as text, - generates via llama-cpp-python's native chat API, and - applies reasoning formatting at the text level. + generates via llama-cpp-python's raw text generation with Harmony encoding, + and applies reasoning formatting at the text level. """ if not self.model: raise RuntimeError("Model not loaded. Call load_model() first.") - from openai_harmony import Role + from openai_harmony import HarmonyEncodingName, Role, load_harmony_encoding effective_max_tokens = self.get_effective_max_tokens( max_tokens, interactive ) - # Convert Harmony Conversation to plain message dicts. - messages = [] - for msg in conversation.messages: - role_str = "user" - if msg.role == Role.SYSTEM: - role_str = "system" - elif msg.role == Role.ASSISTANT: - role_str = "assistant" - elif msg.role == Role.DEVELOPER: - role_str = "system" - content = str(msg.content) if msg.content else "" - if content: - messages.append({"role": role_str, "content": content}) - - output = self.model.create_chat_completion( - messages=messages, + encoding = load_harmony_encoding(HarmonyEncodingName.HARMONY_GPT_OSS) + + # Render the conversation for completion to get token IDs + prompt_tokens = encoding.render_conversation_for_completion( + conversation, Role.ASSISTANT + ) + + # Decode token IDs to a raw string prompt + raw_prompt = encoding.decode(prompt_tokens) + + # Combine stop tokens for safety and accuracy + encoding_stop_tokens = [ + encoding.decode([t]) for t in encoding.stop_tokens_for_assistant_actions() + ] + stop_words = list(set((self._stop_tokens or []) + encoding_stop_tokens)) + + output = self.model( + raw_prompt, max_tokens=effective_max_tokens, temperature=temperature, top_p=top_p, repeat_penalty=repetition_penalty, + stop=stop_words, stream=False, ) - response = output["choices"][0]["message"].get("content", "") + response = output["choices"][0].get("text", "") # pyright: ignore[reportIndexIssue] - # Apply reasoning formatting at text level + # Apply end-token filtering (same as streaming) + response = self._filter_end_tokens_from_response( + response or "", use_chat_stop_tokens=False + ) + + # Format reasoning output response = self._format_reasoning_response(response) + + if self.verbose: + # Rough token count from response length + print(f"\nGenerated in batch mode") + return response # private helpers diff --git a/server/backend/mlx.py b/server/backend/mlx.py index 4a77c0d..07cf6f9 100644 --- a/server/backend/mlx.py +++ b/server/backend/mlx.py @@ -1,9 +1,6 @@ -import json import logging import time import uuid -import random -import string from collections.abc import AsyncGenerator from fastapi import HTTPException from openresponses_types.types import ( @@ -13,10 +10,20 @@ from openresponses_types.types import ( ) from .commons import ( - get_reasoning_effort, + _get_response_on_completed, + _get_response_on_create, + _process_error_event, + _process_init_reasoning_events, + _process_output_item_added, + _process_output_item_delta, + _process_output_item_done, + _process_stop_reasoning_events, + _process_stop_tool_call_events, + _sse, build_harmony_conversation, - is_harmony_family, + get_reasoning_effort, handle_response_input, + is_harmony_family, ) from .commons import get_tool_call_id @@ -158,6 +165,7 @@ async def generate_response_chat_stream( last_state = state state = "toolcall" tool_id = f"toolcall_{uuid.uuid4()}" + # Start fresh so arguments from the last tool call are not reused. I did the same in Linux. tool_call_text = "" content_index = 0 @@ -235,17 +243,6 @@ async def generate_response_chat_stream( # To avoid toolcall tag in the final arguments txt if content_index != 0: tool_call_text += token - output_item = OutputItemDeltaModel( - item_name="function_call_arguments", - item_id=tool_id, - index=output_index, - delta=token, - content_index=content_index, - ) - resp_str, sequence_number = _process_output_item_delta( - output_item, sequence_number - ) - yield resp_str elif state == "answer": if content_index == 0: resp_str, sequence_number = _process_output_item_added( @@ -317,130 +314,6 @@ async def generate_response_chat_stream( return -def _sse(event_name: str, payload: dict, current_seq_no: int) -> tuple[str, int]: - seq_no = current_seq_no + 1 - event = { - "type": event_name, - "sequence_number": seq_no, - } - event.update(payload) - event_str = f"event: {event_name}\ndata: {json.dumps(event)}\n\n" - - return event_str, seq_no - - -def _get_response_on_create( - response_id: str, - request: ResponsesRequest, - created_at: int, -) -> dict: - created_response = { - "id": response_id, - "object": "response", - "created_at": created_at, - "completed_at": None, - "status": "in_progress", - "output": [], - "incomplete_details": None, - "text": {"format": {"type": "text"}, "verbosity": "low"}, - "paralell_tool_calls": 0, - "truncation": "disabled", - "tool_choice": "auto", - "error": {"code": "", "message": ""}, - } - created_response.update(_get_commons_responses(request)) - return created_response - - -def _get_response_on_completed( - response_id: str, - request: ResponsesRequest, - created_at: int, - output: list, - usage: Usage, -) -> dict: - completed_at = int(time.time()) - completed_response = { - "id": response_id, - "object": "response", - "created_at": created_at, - "completed_at": completed_at, - "status": "completed", - "output": output, - "incomplete_details": None, - "text": {"format": {"type": "text"}, "verbosity": "low"}, - "paralell_tool_calls": 0, - "truncation": "disabled", - "tool_choice": "auto", - "error": {"code": "", "message": ""}, - "usage": { - "input_tokens": usage.input_tokens, - "output_tokens": usage.output_tokens, - "total_tokens": usage.total_tokens, - "input_token_details": { - "cached_tokens": usage.input_tokens_details.cached_tokens - }, - "output_token_details": { - "reasoning_tokens": usage.output_tokens_details.reasoning_tokens - }, - }, - } - completed_response.update(_get_commons_responses(request)) - return completed_response - - -def _get_response_on_error( - response_id: str, - request: ResponsesRequest, - created_at: int, - incomplete_details: dict, - error: dict, -) -> dict: - created_response = { - "id": response_id, - "object": "response", - "created_at": created_at, - "completed_at": None, - "status": "failed", - "output": [], - "incomplete_details": incomplete_details, - "text": {"format": {"type": "text"}, "verbosity": "low"}, - "paralell_tool_calls": 0, - "truncation": "disabled", - "tool_choice": "auto", - "error": error, - } - created_response.update(_get_commons_responses(request)) - return created_response - - -def _get_commons_responses(request: ResponsesRequest): - if request.tools != None: - tools_as_dicts = [t.model_dump() for t in request.tools] # pyright: ignore - else: - tools_as_dicts = None - - return { - "model": request.model, - "previous_response_id": request.previous_response_id, - "instructions": request.instructions, - "temperature": request.temperature, - "prompt_cache_key": request.prompt_cache, - "safety_identifier": request.safety_identifier, - "service_tier": request.service_tier, - "background": request.background, - "store": request.store, - "max_tool_calls": request.max_tool_calls, - "max_output_tokens": request.max_output_tokens, - "reasoning": {"effort": request.reasoning.effort, "summary": "disabled"}, - "top_logprobs": request.top_logprobs, - "frequency_penalty": 0, - "presence_penalty": 0, - "top_p": request.top_p, - "tools": tools_as_dicts, - } - - async def _get_runner(model: str): # comms w tiles daemon to get correct model local path response = await client.get( @@ -455,207 +328,3 @@ async def _get_runner(model: str): runner = get_or_load_model(model, model_cache_path) return runner - - -def _process_output_item_delta( - output_item: OutputItemDeltaModel, sequence_number: int -) -> tuple[str, int]: - event_name = ".".join(["response", output_item.item_name, "delta"]) - - event = { - "output_index": output_item.index, - "item_id": output_item.item_id, - "delta": output_item.delta, - "content_index": output_item.content_index, - } - - return _sse(event_name, event, sequence_number) - - -def _process_output_item_added( - type: str, - id: str, - token: str, - output_index, - sequence_number: int, - tool_name: str | None = None, -) -> tuple[str, int]: - event_name = "response.output_item.added" - if type == "function_call": - if not tool_name: - print("Tool name is empty") - item_chunk = { - "type": type, - "id": id, - "name": tool_name, - "call_id": get_tool_call_id(id), - "status": "in_progress", - } - else: - item_chunk = { - "type": type, - "id": id, - "status": "in_progress", - "role": "assistant", - "content": [ - { - "type": "output_text", - "text": token, - } - ], - } - event = { - "output_index": output_index, - "item": item_chunk, - } - return _sse(event_name, event, sequence_number) - - -def _process_init_reasoning_events( - id: str, token: str, output_index, sequence_number: int -) -> tuple[str, int]: - resp_str_a, sequence_number = _process_output_item_added( - "reasoning", id, token, output_index, sequence_number - ) - - event_name = "response.reasoning_summary_part.added" - event = { - "output_index": output_index, - "item_id": id, - "part": {"text": token, "type": "summary_text"}, - "summary_index": 0, - } - resp_str, sequence_number = _sse(event_name, event, sequence_number) - return resp_str_a + resp_str, sequence_number - - -def _process_stop_reasoning_events( - id: str, output_index: int, text: str, sequence_number: int -) -> tuple[str, int, int, dict]: - payload = { - "item_id": id, - "output_index": output_index, - "text": text, - } - resp_str_a, sequence_number = _sse( - "response.reasoning_summary_text.done", payload, sequence_number - ) - event_name = "response.reasoning_summary_part.done" - event = { - "output_index": output_index, - "item_id": id, - "part": {"text": text, "type": "summary_text"}, - "summary_index": 0, - } - resp_str_b, sequence_number = _sse(event_name, event, sequence_number) - resp_str_c, sequence_number, output_index, item_chunk = _process_output_item_done( - "reasoning", id, text, output_index, sequence_number - ) - return ( - resp_str_a + resp_str_b + resp_str_c, - sequence_number, - output_index, - item_chunk, - ) - - -def _process_output_item_done( - type: str, - id: str, - final_text: str, - output_index, - sequence_number: int, - tool_name: str | None = None, -) -> tuple[str, int, int, dict]: - event_name = "response.output_item.done" - item_chunk: dict - if type == "function_call": - try: - arguments_map = json.loads(final_text) - except json.JSONDecodeError as e: - arguments_map = {} - - new_args = { - ("command" if k == "cmd" else k): v for k, v in arguments_map.items() - } - - item_chunk = { - "type": type, - "id": id, - "name": tool_name, - "call_id": get_tool_call_id(id), - "status": "completed", - "arguments": json.dumps(new_args), - } - else: - item_chunk = { - "type": type, - "id": id, - "status": "completed", - "role": "assistant", - "content": [ - { - "type": "output_text", - "text": final_text, - } - ], - } - if type == "reasoning": - item_chunk.update({"summary": [{"type": "summary_text", "text": final_text}]}) - event = { - "output_index": output_index, - "item": item_chunk, - } - resp_str, sequence_number = _sse(event_name, event, sequence_number) - output_index = output_index + 1 - return resp_str, sequence_number, output_index, item_chunk - - -def _process_error_event( - err: str, - response_id: str, - request: ResponsesRequest, - created_at: int, - sequence_number: int, -) -> tuple[str, int]: - error = {"message": err, "code": "500"} - incomplete_details = {"reason": "internal server error"} - - err_response = _get_response_on_error( - response_id, request, created_at, incomplete_details, error - ) - return _sse("response.failed", {"response": err_response}, sequence_number) - - -def _process_stop_tool_call_events( - id: str, - output_index: int, - text: str, - sequence_number: int, - tool_name: str | None = None, -) -> tuple[str, int, int, dict]: - event_name = "response.function_call_arguments.done" - - try: - arguments_map = json.loads(text) - except json.JSONDecodeError as e: - arguments_map = {} - - new_args = {("command" if k == "cmd" else k): v for k, v in arguments_map.items()} - - event = { - "output_index": output_index, - "item_id": id, - "name": tool_name, - "arguments": json.dumps(new_args), - } - resp_str_a, sequence_number = _sse(event_name, event, sequence_number) - resp_str_b, sequence_number, output_index, item_chunk = _process_output_item_done( - "function_call", id, text, output_index, sequence_number, tool_name - ) - return ( - resp_str_a + resp_str_b, - sequence_number, - output_index, - item_chunk, - ) diff --git a/server/pyproject.toml b/server/pyproject.toml index d995487..2c252c9 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -25,7 +25,7 @@ build-backend = "setuptools.build_meta" exclude = ["backend", "backend.*"] [tool.uv] -exclude-newer="2026-04-05T09:43:13.300458+00:00" +exclude-newer="2026-06-02T19:44:30+05:30" [dependency-groups] dev = [ diff --git a/server/schemas.py b/server/schemas.py index b601e1e..9d14dc4 100644 --- a/server/schemas.py +++ b/server/schemas.py @@ -239,6 +239,11 @@ class GenerationMetrics: total_latency_s: float # End-to-end latency in seconds +@dataclass +class ToolCallStart: + name: str + + from enum import IntEnum diff --git a/server/stack/requirements/app-server/packages-app-server.txt b/server/stack/requirements/app-server/packages-app-server.txt index e5fb67f..70d098e 100644 --- a/server/stack/requirements/app-server/packages-app-server.txt +++ b/server/stack/requirements/app-server/packages-app-server.txt @@ -4,45 +4,47 @@ annotated-doc==0.0.4 annotated-types==0.7.0 anyio==4.13.0 black==25.9.0 -certifi==2026.2.25 -click==8.3.2 +certifi==2026.5.20 +click==8.4.1 +diskcache==5.6.3 ; platform_machine == 'x86_64' and sys_platform == 'linux' fastapi==0.119.0 -filelock==3.25.2 -fsspec==2026.3.0 +filelock==3.29.0 +fsspec==2026.4.0 h11==0.16.0 -hf-xet==1.4.3 +hf-xet==1.5.0 httpcore==1.0.9 httpx==0.28.1 -huggingface-hub==1.9.0 -idna==3.11 +huggingface-hub==1.17.0 +idna==3.17 jinja2==3.1.6 -markdown-it-py==4.0.0 +llama-cpp-python==0.3.25 ; platform_machine == 'x86_64' and sys_platform == 'linux' +markdown-it-py==4.2.0 markupsafe==3.0.3 mdurl==0.1.2 mlx-lm==0.31.0 mypy-extensions==1.1.0 -numpy==2.4.4 +numpy==2.4.6 openai-harmony==0.0.8 openresponses-types==2.3.0.post1 -packaging==26.0 -pathspec==1.0.4 -platformdirs==4.9.4 -protobuf==7.34.1 -pydantic==2.12.5 -pydantic-core==2.41.5 +packaging==26.2 +pathspec==1.1.1 +platformdirs==4.10.0 +protobuf==7.35.0 +pydantic==2.13.4 +pydantic-core==2.46.4 pygments==2.20.0 pytokens==0.4.1 pyyaml==6.0.3 -regex==2026.4.4 -rich==14.3.3 +regex==2026.5.9 +rich==15.0.0 safetensors==0.7.0 sentencepiece==0.2.1 shellingham==1.5.4 starlette==0.48.0 tokenizers==0.22.2 tqdm==4.67.3 -transformers==5.5.0 -typer==0.24.1 +transformers==5.9.0 +typer==0.25.1 typing-extensions==4.15.0 typing-inspection==0.4.2 uvicorn==0.38.0 diff --git a/server/stack/requirements/app-server/pylock.app-server.meta.json b/server/stack/requirements/app-server/pylock.app-server.meta.json index 25fd480..7e2334c 100644 --- a/server/stack/requirements/app-server/pylock.app-server.meta.json +++ b/server/stack/requirements/app-server/pylock.app-server.meta.json @@ -1,8 +1,8 @@ { - "lock_input_hash": "sha256:3c4cafa1e5147c3de588c39652d8ba0046be8d1bea5ae8a547a7bb8b1bb9e734", + "lock_input_hash": "sha256:23c918835fc67d6f14f1ddd338a853ff65b2bc304d519b8e2f8cfbdb41823e10", "lock_version": 1, - "locked_at": "2026-04-05T09:43:13.300458+00:00", - "other_inputs_hash": "sha256:63b3c2cfe2ec414938e81dace7aac779c7b902bae681618cd8827e9f16880985", - "requirements_hash": "sha256:ee39e2bf91056148cbeebb9929b55faf49c0a46109b2f4076b7d3e22d76daac0", + "locked_at": "2026-06-02T14:31:04.744705+00:00", + "other_inputs_hash": "sha256:b6e3f918a29cd57ba53717cbdd726a48d63b5ff61e6d244441409d73f089c908", + "requirements_hash": "sha256:eec2b0a068ad7fe1efe2f3a418dba5b4c25a712d73d733fe38ccb10a97f93680", "version_inputs_hash": "sha256:58db986b7cd72eeded675f7c9afd8138fe024fb51451131b5562922bbde3cf43" } diff --git a/server/stack/requirements/app-server/pylock.app-server.toml b/server/stack/requirements/app-server/pylock.app-server.toml index f7b5aee..8d7e646 100644 --- a/server/stack/requirements/app-server/pylock.app-server.toml +++ b/server/stack/requirements/app-server/pylock.app-server.toml @@ -75,29 +75,43 @@ sha256 = "474b34c1342cdc157d307b56c4c65bce916480c4a8f6551fdc6bf9b486a7c4ae" [[packages]] name = "certifi" -version = "2026.2.25" +version = "2026.5.20" index = "https://pypi.org/simple" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl" -upload-time = 2026-02-25T02:54:15Z -size = 153684 +url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl" +upload-time = 2026-05-20T11:46:48Z +size = 134134 [packages.wheels.hashes] -sha256 = "027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa" +sha256 = "3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897" [[packages]] name = "click" -version = "8.3.2" +version = "8.4.1" index = "https://pypi.org/simple" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl" -upload-time = 2026-04-03T19:14:43Z -size = 108379 +url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl" +upload-time = 2026-05-22T04:08:35Z +size = 116639 [packages.wheels.hashes] -sha256 = "1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d" +sha256 = "482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2" + +[[packages]] +name = "diskcache" +version = "5.6.3" +marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" +index = "https://pypi.org/simple" + +[[packages.wheels]] +url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl" +upload-time = 2023-08-31T06:11:58Z +size = 45550 + +[packages.wheels.hashes] +sha256 = "5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19" [[packages]] name = "fastapi" @@ -114,29 +128,29 @@ sha256 = "90a2e49ed19515320abb864df570dd766be0662c5d577688f1600170f7f73cf2" [[packages]] name = "filelock" -version = "3.25.2" +version = "3.29.0" index = "https://pypi.org/simple" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl" -upload-time = 2026-03-11T20:45:37Z -size = 26759 +url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl" +upload-time = 2026-04-19T15:39:08Z +size = 39812 [packages.wheels.hashes] -sha256 = "ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70" +sha256 = "96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258" [[packages]] name = "fsspec" -version = "2026.3.0" +version = "2026.4.0" index = "https://pypi.org/simple" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/d5/1f/5f4a3cd9e4440e9d9bc78ad0a91a1c8d46b4d429d5239ebe6793c9fe5c41/fsspec-2026.3.0-py3-none-any.whl" -upload-time = 2026-03-27T19:11:13Z -size = 202595 +url = "https://files.pythonhosted.org/packages/d5/0c/043d5e551459da400957a1395e0febbf771446ff34291afcbe3d8be2a279/fsspec-2026.4.0-py3-none-any.whl" +upload-time = 2026-04-29T20:42:36Z +size = 203402 [packages.wheels.hashes] -sha256 = "d2ceafaad1b3457968ed14efa28798162f1638dbb5d2a6868a2db002a5ee39a4" +sha256 = "11ef7bb35dab8a394fde6e608221d5cf3e8499401c249bebaeaad760a1a8dec2" [[packages]] name = "h11" @@ -153,56 +167,56 @@ sha256 = "63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86" [[packages]] name = "hf-xet" -version = "1.4.3" +version = "1.5.0" index = "https://pypi.org/simple" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/2b/d2/8bee5996b699262edb87dbb54118d287c0e1b2fc78af7cdc41857ba5e3c4/hf_xet-1.4.3-cp313-cp313t-macosx_11_0_arm64.whl" -upload-time = 2026-03-31T22:39:47Z -size = 3558942 +url = "https://files.pythonhosted.org/packages/0f/6d/9563cfde59b5d8128a9c7ec972a087f4c782e4f7bac5a85234edfd5d5e49/hf_xet-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl" +upload-time = 2026-05-06T06:17:51Z +size = 3792751 [packages.wheels.hashes] -sha256 = "bee693ada985e7045997f05f081d0e12c4c08bd7626dc397f8a7c487e6c04f7f" +sha256 = "73a0dae8c71de3b0633a45c73f4a4a5ed09e94b43441d82981a781d4f12baa42" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/c3/a1/e993d09cbe251196fb60812b09a58901c468127b7259d2bf0f68bf6088eb/hf_xet-1.4.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" -upload-time = 2026-03-31T22:39:39Z -size = 4207657 +url = "https://files.pythonhosted.org/packages/07/a5/ed5a0cf35b49a0571af5a8f53416dad1877a718c021c9937c3a53cb45781/hf_xet-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +upload-time = 2026-05-06T06:17:40Z +size = 4456058 [packages.wheels.hashes] -sha256 = "21644b404bb0100fe3857892f752c4d09642586fd988e61501c95bbf44b393a3" +sha256 = "a60290ec57e9b71767fba7c3645ddafdd0759974b540441510c629c6db6db24a" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/1b/c4/39d6e136cbeea9ca5a23aad4b33024319222adbdc059ebcda5fc7d9d5ff4/hf_xet-1.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl" -upload-time = 2026-03-31T22:40:00Z -size = 4424525 +url = "https://files.pythonhosted.org/packages/13/59/c74efbbd4e8728172b2cc72a2bc014d2947a4b7bdced932fbd3f5da1a4e5/hf_xet-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl" +upload-time = 2026-05-06T06:18:06Z +size = 4663995 [packages.wheels.hashes] -sha256 = "2815a49a7a59f3e2edf0cf113ae88e8cb2ca2a221bf353fb60c609584f4884d4" +sha256 = "2baea1b0b989e5c152fe81425f7745ddc8901280ba3d97c98d8cdece7b706c60" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/0b/f8/7aacb8e5f4a7899d39c787b5984e912e6c18b11be136ef13947d7a66d265/hf_xet-1.4.3-cp37-abi3-macosx_11_0_arm64.whl" -upload-time = 2026-03-31T22:39:51Z -size = 3562178 +url = "https://files.pythonhosted.org/packages/9b/ff/edcc2b40162bef3ff78e14ab637e5f3b89243d6aee72f5949d3bb6a5af83/hf_xet-1.5.0-cp37-abi3-macosx_11_0_arm64.whl" +upload-time = 2026-05-06T06:17:55Z +size = 3798444 [packages.wheels.hashes] -sha256 = "e23717ce4186b265f69afa66e6f0069fe7efbf331546f5c313d00e123dc84583" +sha256 = "fd6e5a9b0fdac4ed03ed45ef79254a655b1aaab514a02202617fbf643f5fdf7a" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/df/9a/a24b26dc8a65f0ecc0fe5be981a19e61e7ca963b85e062c083f3a9100529/hf_xet-1.4.3-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" -upload-time = 2026-03-31T22:39:42Z -size = 4212320 +url = "https://files.pythonhosted.org/packages/49/4d/103f76b04310e5e57656696cc184690d20c466af0bca3ca88f8c8ea5d4f3/hf_xet-1.5.0-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +upload-time = 2026-05-06T06:17:44Z +size = 4465986 [packages.wheels.hashes] -sha256 = "fc360b70c815bf340ed56c7b8c63aacf11762a4b099b2fe2c9bd6d6068668c08" +sha256 = "3531b1823a0e6d77d80f9ed15ca0e00f0d115094f8ac033d5cae88f4564cc949" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/42/4b/53951592882d9c23080c7644542fda34a3813104e9e11fa1a7d82d419cb8/hf_xet-1.4.3-cp37-abi3-musllinux_1_2_x86_64.whl" -upload-time = 2026-03-31T22:40:03Z -size = 4429392 +url = "https://files.pythonhosted.org/packages/aa/b2/703569fc881f3284487e68cda7b42179978480da3c438042a6bbbb4a671c/hf_xet-1.5.0-cp37-abi3-musllinux_1_2_x86_64.whl" +upload-time = 2026-05-06T06:18:09Z +size = 4672414 [packages.wheels.hashes] -sha256 = "7716d62015477a70ea272d2d68cd7cad140f61c52ee452e133e139abfe2c17ba" +sha256 = "4b35549ce62601b84da4ff9b24d970032ace3d4430f52d91bcbb26c901d6c690" [[packages]] name = "httpcore" @@ -232,29 +246,29 @@ sha256 = "d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad" [[packages]] name = "huggingface-hub" -version = "1.9.0" +version = "1.17.0" index = "https://pypi.org/simple" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/73/37/0d15d16150e1829f3e90962c99f28257f6de9e526a680b4c6f5acdb54fd2/huggingface_hub-1.9.0-py3-none-any.whl" -upload-time = 2026-04-03T08:35:53Z -size = 637355 +url = "https://files.pythonhosted.org/packages/02/28/d7cef5e477b855c25d415b8f57e5bc7347c7a90cad3acf1725d0c92ca294/huggingface_hub-1.17.0-py3-none-any.whl" +upload-time = 2026-05-28T15:12:11Z +size = 671546 [packages.wheels.hashes] -sha256 = "2999328c058d39fd19ab748dd09bd4da2fbaa4f4c1ddea823eab103051e14a1f" +sha256 = "3b8156d23118e87f6a587648bfbc04f04a12a757ccb4ed298b35c4ae638bf24c" [[packages]] name = "idna" -version = "3.11" +version = "3.17" index = "https://pypi.org/simple" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl" -upload-time = 2025-10-12T14:55:18Z -size = 71008 +url = "https://files.pythonhosted.org/packages/de/a7/f76514cc40ad6234098ecdebda08732d75964776c51a42845b7da10649e2/idna-3.17-py3-none-any.whl" +upload-time = 2026-05-28T14:32:37Z +size = 65316 [packages.wheels.hashes] -sha256 = "771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea" +sha256 = "466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c" [[packages]] name = "jinja2" @@ -269,18 +283,29 @@ size = 134899 [packages.wheels.hashes] sha256 = "85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67" +[[packages]] +name = "llama-cpp-python" +version = "0.3.25" +marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" +index = "https://abetlen.github.io/llama-cpp-python/whl/cu124" + +[[packages.wheels]] +url = "https://github.com/abetlen/llama-cpp-python/releases/download/v0.3.25-cu124/llama_cpp_python-0.3.25-py3-none-linux_x86_64.whl" + +[packages.wheels.hashes] + [[packages]] name = "markdown-it-py" -version = "4.0.0" +version = "4.2.0" index = "https://pypi.org/simple" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl" -upload-time = 2025-08-11T12:57:51Z -size = 87321 +url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl" +upload-time = 2026-05-07T12:08:27Z +size = 91687 [packages.wheels.hashes] -sha256 = "87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147" +sha256 = "9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a" [[packages]] name = "markupsafe" @@ -386,72 +411,72 @@ sha256 = "1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505" [[packages]] name = "numpy" -version = "2.4.4" +version = "2.4.6" index = "https://pypi.org/simple" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl" -upload-time = 2026-03-29T13:19:25Z -size = 14694532 +url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl" +upload-time = 2026-05-18T23:34:33Z +size = 14693902 [packages.wheels.hashes] -sha256 = "e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115" +sha256 = "bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl" -upload-time = 2026-03-29T13:19:28Z -size = 5199661 +url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl" +upload-time = 2026-05-18T23:34:36Z +size = 5198992 [packages.wheels.hashes] -sha256 = "4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af" +sha256 = "043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl" -upload-time = 2026-03-29T13:19:37Z -size = 16632682 +url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl" +upload-time = 2026-05-18T23:34:45Z +size = 16633220 [packages.wheels.hashes] -sha256 = "c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83" +sha256 = "a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl" -upload-time = 2026-03-29T13:19:44Z -size = 18357394 +url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl" +upload-time = 2026-05-18T23:34:52Z +size = 18357600 [packages.wheels.hashes] -sha256 = "0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959" +sha256 = "dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl" -upload-time = 2026-03-29T13:19:57Z -size = 14822302 +url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl" +upload-time = 2026-05-18T23:35:05Z +size = 14821197 [packages.wheels.hashes] -sha256 = "4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5" +sha256 = "112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl" -upload-time = 2026-03-29T13:20:00Z -size = 5327407 +url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl" +upload-time = 2026-05-18T23:35:08Z +size = 5326287 [packages.wheels.hashes] -sha256 = "4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7" +sha256 = "eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl" -upload-time = 2026-03-29T13:20:09Z -size = 16681241 +url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl" +upload-time = 2026-05-18T23:35:18Z +size = 16681752 [packages.wheels.hashes] -sha256 = "5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40" +sha256 = "ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl" -upload-time = 2026-03-29T13:20:17Z -size = 18403169 +url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl" +upload-time = 2026-05-18T23:35:26Z +size = 18403398 [packages.wheels.hashes] -sha256 = "4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392" +sha256 = "a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd" [[packages]] name = "openai-harmony" @@ -497,113 +522,113 @@ sha256 = "88f6abcef9cad839203abff420dd080978bf6eb33cc06ddc5d78da4ccdba7613" [[packages]] name = "packaging" -version = "26.0" +version = "26.2" index = "https://pypi.org/simple" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl" -upload-time = 2026-01-21T20:50:37Z -size = 74366 +url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl" +upload-time = 2026-04-24T20:15:22Z +size = 100195 [packages.wheels.hashes] -sha256 = "b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529" +sha256 = "5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e" [[packages]] name = "pathspec" -version = "1.0.4" +version = "1.1.1" index = "https://pypi.org/simple" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl" -upload-time = 2026-01-27T03:59:45Z -size = 55206 +url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl" +upload-time = 2026-04-27T01:46:07Z +size = 57328 [packages.wheels.hashes] -sha256 = "fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723" +sha256 = "a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189" [[packages]] name = "platformdirs" -version = "4.9.4" +version = "4.10.0" index = "https://pypi.org/simple" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl" -upload-time = 2026-03-05T18:34:12Z -size = 21216 +url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl" +upload-time = 2026-05-28T03:32:52Z +size = 22743 [packages.wheels.hashes] -sha256 = "68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868" +sha256 = "fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a" [[packages]] name = "protobuf" -version = "7.34.1" +version = "7.35.0" index = "https://pypi.org/simple" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/ec/11/3325d41e6ee15bf1125654301211247b042563bcc898784351252549a8ad/protobuf-7.34.1-cp310-abi3-macosx_10_9_universal2.whl" -upload-time = 2026-03-20T17:34:37Z -size = 429247 +url = "https://files.pythonhosted.org/packages/83/ee/93d06e358a4aa32280b00e722d3ea0a1f25fc3cc5778d80581c9cca2c10e/protobuf-7.35.0-cp310-abi3-macosx_10_9_universal2.whl" +upload-time = 2026-05-19T23:02:19Z +size = 433225 [packages.wheels.hashes] -sha256 = "d8b2cc79c4d8f62b293ad9b11ec3aebce9af481fa73e64556969f7345ebf9fc7" +sha256 = "66be6c513931c794fa92c080ffee41671390da3d79da219cf9c0c0907f035dda" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/53/1b/3b431694a4dc6d37b9f653f0c64b0a0d9ec074ee810710c0c3da21d67ba7/protobuf-7.34.1-cp310-abi3-manylinux2014_x86_64.whl" -upload-time = 2026-03-20T17:34:41Z -size = 324267 +url = "https://files.pythonhosted.org/packages/70/5b/6baf9008817964454055ff3fe65f1de0b5f1e26c80c82f7fb108b7cd4ea3/protobuf-7.35.0-cp310-abi3-manylinux2014_x86_64.whl" +upload-time = 2026-05-19T23:02:24Z +size = 327130 [packages.wheels.hashes] -sha256 = "8ff40ce8cd688f7265326b38d5a1bed9bfdf5e6723d49961432f83e21d5713e4" +sha256 = "6c0f98f10c8a05ea30f8993dfef2de093d27b490fdae78bb60c8343795d55011" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/88/95/608f665226bca68b736b79e457fded9a2a38c4f4379a4a7614303d9db3bc/protobuf-7.34.1-py3-none-any.whl" -upload-time = 2026-03-20T17:34:45Z -size = 170715 +url = "https://files.pythonhosted.org/packages/b8/ef/50433d346c56657a70d27f156c7b349ac59a068b01de4eb796e747eecc43/protobuf-7.35.0-py3-none-any.whl" +upload-time = 2026-05-19T23:02:27Z +size = 171659 [packages.wheels.hashes] -sha256 = "bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11" +sha256 = "c13f325cf242bad135c350629eeb5d54b24228eb472fb3e2e9ebbd4c5dc20ca0" [[packages]] name = "pydantic" -version = "2.12.5" +version = "2.13.4" index = "https://pypi.org/simple" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl" -upload-time = 2025-11-26T15:11:44Z -size = 463580 +url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl" +upload-time = 2026-05-06T13:43:02Z +size = 472262 [packages.wheels.hashes] -sha256 = "e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d" +sha256 = "45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba" [[packages]] name = "pydantic-core" -version = "2.41.5" +version = "2.46.4" index = "https://pypi.org/simple" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl" -upload-time = 2025-11-04T13:40:27Z -size = 1896206 +url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl" +upload-time = 2026-05-06T13:37:17Z +size = 1951906 [packages.wheels.hashes] -sha256 = "112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34" +sha256 = "c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" -upload-time = 2025-11-04T13:40:40Z -size = 2073164 +url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" +upload-time = 2026-05-06T13:39:18Z +size = 2094467 [packages.wheels.hashes] -sha256 = "406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586" +sha256 = "9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl" -upload-time = 2025-11-04T13:40:48Z -size = 2324852 +url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl" +upload-time = 2026-05-06T13:39:34Z +size = 2372986 [packages.wheels.hashes] -sha256 = "915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858" +sha256 = "82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d" [[packages]] name = "pygments" @@ -686,85 +711,85 @@ sha256 = "eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be" [[packages]] name = "regex" -version = "2026.4.4" +version = "2026.5.9" index = "https://pypi.org/simple" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/9d/83/c4373bc5f31f2cf4b66f9b7c31005bd87fe66f0dce17701f7db4ee79ee29/regex-2026.4.4-cp313-cp313-macosx_10_13_universal2.whl" -upload-time = 2026-04-03T20:54:11Z -size = 490273 +url = "https://files.pythonhosted.org/packages/aa/da/797e91ecec6f84135da778ddce78c20e0af5d2a15c26f87a81bc3eadb6db/regex-2026.5.9-cp313-cp313-macosx_10_13_universal2.whl" +upload-time = 2026-05-09T23:13:04Z +size = 490303 [packages.wheels.hashes] -sha256 = "62f5519042c101762509b1d717b45a69c0139d60414b3c604b81328c01bd1943" +sha256 = "d626b84406444b165fc0ba981604edea39f0588ff1f92baa23fe50799ea9afdb" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/5a/92/4712b9fe6a33d232eeb1c189484b80c6c4b8422b90e766e1195d6e758207/regex-2026.4.4-cp313-cp313-macosx_11_0_arm64.whl" -upload-time = 2026-04-03T20:54:15Z -size = 289487 +url = "https://files.pythonhosted.org/packages/2d/e7/d0eaf5713828417b9e5648cf81fa9bacd4961f6ab98c380c2034f8716e35/regex-2026.5.9-cp313-cp313-macosx_11_0_arm64.whl" +upload-time = 2026-05-09T23:13:08Z +size = 289468 [packages.wheels.hashes] -sha256 = "8fae3c6e795d7678963f2170152b0d892cf6aee9ee8afc8c45e6be38d5107fe7" +sha256 = "a8820737949116ffff55fe18f9fc644530063ba6ebfcb8314239416e78f1347c" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/26/47/2ee5c613ab546f0eddebf9905d23e07beb933416b1246c2d8791d01979b4/regex-2026.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" -upload-time = 2026-04-03T20:54:24Z -size = 801126 +url = "https://files.pythonhosted.org/packages/30/e1/c93444052cf41581f3c884ab3fb5823daf0992f11cd4388d4275ca610558/regex-2026.5.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" +upload-time = 2026-05-09T23:13:16Z +size = 801269 [packages.wheels.hashes] -sha256 = "ffa81f81b80047ba89a3c69ae6a0f78d06f4a42ce5126b0eb2a0a10ad44e0b2e" +sha256 = "b6d189041f15691cfa2b6c4290448ec221244d225b3f5fe9e7771b34ffcdf6e2" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/f7/0d/c813f0af7c6cc7ed7b9558bac2e5120b60ad0fa48f813e4d4bd55446f214/regex-2026.4.4-cp313-cp313-musllinux_1_2_x86_64.whl" -upload-time = 2026-04-03T20:54:36Z -size = 789181 +url = "https://files.pythonhosted.org/packages/5b/92/7eebc0d0a01e78629695f342ba17e0deaff8fb45e79cc0d7b98287da6e3e/regex-2026.5.9-cp313-cp313-musllinux_1_2_x86_64.whl" +upload-time = 2026-05-09T23:13:27Z +size = 789577 [packages.wheels.hashes] -sha256 = "c882cd92ec68585e9c1cf36c447ec846c0d94edd706fe59e0c198e65822fd23b" +sha256 = "2efa205e6d98b24d1f3ab395c11aa15cdf10935bca283d0285e0499c284fba21" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/f1/1e/3a2b9672433bef02f5d39aa1143ca2c08f311c1d041c464a42be9ae648dc/regex-2026.4.4-cp313-cp313t-macosx_10_13_universal2.whl" -upload-time = 2026-04-03T20:54:43Z -size = 494126 +url = "https://files.pythonhosted.org/packages/e8/e9/d21346f7b60ed58789371358ed66b09d00f832e1bd7c06e55d9da5679882/regex-2026.5.9-cp313-cp313t-macosx_10_13_universal2.whl" +upload-time = 2026-05-09T23:13:35Z +size = 494172 [packages.wheels.hashes] -sha256 = "f94a11a9d05afcfcfa640e096319720a19cc0c9f7768e1a61fceee6a3afc6c7c" +sha256 = "01f28d868834624c934b8d2e0aa1c8341337e37831f4a012f18a5afcba4cbaf3" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/f4/5f/eaa38092ce7a023656280f2341dbbd4ad5f05d780a70abba7bb4f4bea54c/regex-2026.4.4-cp313-cp313t-macosx_11_0_arm64.whl" -upload-time = 2026-04-03T20:54:47Z -size = 292334 +url = "https://files.pythonhosted.org/packages/f2/7d/9fbf919768368d3f8a4f6c692cf2aa61e482b2b81ec6a298ace4cbf02480/regex-2026.5.9-cp313-cp313t-macosx_11_0_arm64.whl" +upload-time = 2026-05-09T23:13:40Z +size = 292314 [packages.wheels.hashes] -sha256 = "261c015b3e2ed0919157046d768774ecde57f03d8fa4ba78d29793447f70e717" +sha256 = "b96350aa424e79d4fd6b567b344dcbe2b2d6bfc48dfe7717587e1fa6d43da6ff" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/5c/71/8b260897f22996b666edd9402861668f45a2ca259f665ac029e6104a2d7d/regex-2026.4.4-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" -upload-time = 2026-04-03T20:54:54Z -size = 816358 +url = "https://files.pythonhosted.org/packages/ae/ff/8db60211e2286e396aad7dc7725356c502bff0901ea05bd6cdc2e1a042b9/regex-2026.5.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" +upload-time = 2026-05-09T23:13:49Z +size = 816311 [packages.wheels.hashes] -sha256 = "0734f63afe785138549fbe822a8cfeaccd1bae814c5057cc0ed5b9f2de4fc883" +sha256 = "728d8bfd28a8845c8b6bc5dc7ce010453d206396786c0765c2740cb65f37791e" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/4d/b4/c671db3556be2473ae3e4bb7a297c518d281452871501221251ea4ecba57/regex-2026.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl" -upload-time = 2026-04-03T20:55:07Z -size = 803241 +url = "https://files.pythonhosted.org/packages/a9/29/92ff47f75990131ea4f24ba17819e5a9d141e10819807e09addd73409af6/regex-2026.5.9-cp313-cp313t-musllinux_1_2_x86_64.whl" +upload-time = 2026-05-09T23:14:01Z +size = 803453 [packages.wheels.hashes] -sha256 = "f4f83781191007b6ef43b03debc35435f10cad9b96e16d147efe84a1d48bdde4" +sha256 = "dfbe4579b9f08036aa7d101d1835437a20783574ac66327e6b29b4018a138081" [[packages]] name = "rich" -version = "14.3.3" +version = "15.0.0" index = "https://pypi.org/simple" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl" -upload-time = 2026-02-19T17:23:13Z -size = 310458 +url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl" +upload-time = 2026-04-12T08:24:02Z +size = 310654 [packages.wheels.hashes] -sha256 = "793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d" +sha256 = "33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb" [[packages]] name = "safetensors" @@ -918,29 +943,29 @@ sha256 = "ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf" [[packages]] name = "transformers" -version = "5.5.0" +version = "5.9.0" index = "https://pypi.org/simple" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/e7/28/35f7411ff80a3640c1f4fc907dcbb6a65061ebb82f66950e38bfc9f7f740/transformers-5.5.0-py3-none-any.whl" -upload-time = 2026-04-02T16:13:03Z -size = 10245591 +url = "https://files.pythonhosted.org/packages/02/ca/2eaa5359f2ccb8c2e1656bc26305ad0cf438aa392ce4b29ae67a315c186e/transformers-5.9.0-py3-none-any.whl" +upload-time = 2026-05-20T14:50:45Z +size = 10787648 [packages.wheels.hashes] -sha256 = "821a9ff0961abbb29eb1eb686d78df1c85929fdf213a3fe49dc6bd94f9efa944" +sha256 = "1d19509bcff7028ebc6b277d71caa712e8353778463d38764237d14b42b52788" [[packages]] name = "typer" -version = "0.24.1" +version = "0.25.1" index = "https://pypi.org/simple" [[packages.wheels]] -url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl" -upload-time = 2026-02-21T16:54:41Z -size = 56085 +url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl" +upload-time = 2026-04-30T19:32:18Z +size = 58409 [packages.wheels.hashes] -sha256 = "112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e" +sha256 = "75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89" [[packages]] name = "typing-extensions" diff --git a/server/stack/requirements/app-server/requirements-app-server.in b/server/stack/requirements/app-server/requirements-app-server.in index 8bfa8b9..1b0931b 100644 --- a/server/stack/requirements/app-server/requirements-app-server.in +++ b/server/stack/requirements/app-server/requirements-app-server.in @@ -5,5 +5,6 @@ uvicorn==0.38.0 mlx-lm==0.31.0 black==25.9.0 openai-harmony==0.0.8 +llama-cpp-python==0.3.25; sys_platform == "linux" openresponses-types httpx==0.28.1 diff --git a/server/stack/requirements/cpython3.13/pylock.cpython3_13.meta.json b/server/stack/requirements/cpython3.13/pylock.cpython3_13.meta.json index 9c41b62..5173bbd 100644 --- a/server/stack/requirements/cpython3.13/pylock.cpython3_13.meta.json +++ b/server/stack/requirements/cpython3.13/pylock.cpython3_13.meta.json @@ -1,8 +1,8 @@ { "lock_input_hash": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "lock_version": 1, - "locked_at": "2026-01-21T09:13:57.711430+00:00", - "other_inputs_hash": "sha256:ca226edbf868f428c8ed9b1a15916850a28cfb647126150523fcf41443fe072d", + "locked_at": "2026-06-02T14:14:30+00:00", + "other_inputs_hash": "sha256:c64981dc69ad92a6a4daa4a8e044f3b181bfd2e9b7100272612199e5bf84734f", "requirements_hash": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "version_inputs_hash": "sha256:083d4af739b0bca1ef56cddec2d51068dcae912c76221925758d8f7339585e48" } diff --git a/server/stack/venvstacks.toml b/server/stack/venvstacks.toml index 1453a92..2afbd44 100644 --- a/server/stack/venvstacks.toml +++ b/server/stack/venvstacks.toml @@ -27,7 +27,7 @@ requirements = [ "mlx-lm==0.31.0", "black==25.9.0", "openai-harmony==0.0.8", - "llama-cpp-python; sys_platform == 'linux'", + "llama-cpp-python==0.3.25; sys_platform == 'linux'", "openresponses-types", "httpx==0.28.1", # "oss-harmony @ git+https://github.com/oss-harmony/harmony.git@main" @@ -37,10 +37,9 @@ requirements = [ url = "https://abetlen.github.io/llama-cpp-python/whl/cu124" name = "llama_cpp-python-cuda" -[tool.uv] -exclude-newer="2026-04-05T09:43:13.300458+00:00" - +# This index does not include upload times, so exclude-newer filters its Linux wheels. # Only resolve for the relevant target platforms +[tool.uv] environments = [ "sys_platform == 'darwin' and platform_machine == 'arm64'", "sys_platform == 'linux' and platform_machine == 'x86_64'", diff --git a/server/tests/test_commons.py b/server/tests/test_commons.py new file mode 100644 index 0000000..e168ef8 --- /dev/null +++ b/server/tests/test_commons.py @@ -0,0 +1,51 @@ +from openai_harmony import HarmonyEncodingName, ReasoningEffort, Role, load_harmony_encoding + +from server.backend.commons import build_harmony_conversation +from server.schemas import ResponsesRequest + + +def test_harmony_conversation_replays_function_call_before_output(): + request = ResponsesRequest.model_validate( + { + "model": "unsloth/gpt-oss-20b-GGUF", + "input": [ + { + "role": "user", + "content": [{"type": "input_text", "text": "read changelog.md"}], + }, + { + "type": "function_call", + "id": "toolcall_1", + "call_id": "call_1", + "name": "read", + "arguments": '{"path":"changelog.md"}', + }, + { + "type": "function_call_output", + "call_id": "call_1", + "output": "contents", + }, + ], + } + ) + + conversation = build_harmony_conversation( + ReasoningEffort.LOW, request.input, replay_function_calls=True + ) + encoding = load_harmony_encoding(HarmonyEncodingName.HARMONY_GPT_OSS) + prompt = encoding.decode( + encoding.render_conversation_for_completion(conversation, Role.ASSISTANT) + ) + + assert ( + "<|start|>assistant<|channel|>commentary to=read <|constrain|>json" + '<|message|>{"path":"changelog.md"}<|call|>' + "<|start|>read<|channel|>commentary<|message|>contents<|end|>" + ) in prompt + + default_conversation = build_harmony_conversation(ReasoningEffort.LOW, request.input) + default_prompt = encoding.decode( + encoding.render_conversation_for_completion(default_conversation, Role.ASSISTANT) + ) + + assert "<|start|>assistant<|channel|>commentary to=read" not in default_prompt diff --git a/server/tests/test_linux_streaming.py b/server/tests/test_linux_streaming.py new file mode 100644 index 0000000..92b57f0 --- /dev/null +++ b/server/tests/test_linux_streaming.py @@ -0,0 +1,120 @@ +import json +from unittest.mock import patch + +import pytest + +from server.backend import linux +from server.schemas import ResponsesRequest, ToolCallStart + + +class FakeModel: + def tokenize(self, text, add_bos=False): + return text.split() + + +class FakeRunner: + model = FakeModel() + tool_name = "read" + + def get_effective_max_tokens(self, max_output_tokens): + return max_output_tokens or 128 + + def generate_streaming_gpt(self, **kwargs): + yield "**[Reasoning]**\n\n" + yield "Need to read file." + yield ToolCallStart(self.tool_name) + yield '{"path":"changelog.md"}' + + +def make_request(): + return ResponsesRequest.model_validate( + { + "model": "unsloth/gpt-oss-20b-GGUF", + "input": [ + { + "role": "user", + "content": [{"type": "input_text", "text": "read changelog.md"}], + } + ], + "stream": True, + "tools": [ + { + "type": "function", + "name": "read", + "parameters": { + "type": "object", + "required": ["path"], + "properties": {"path": {"type": "string"}}, + }, + } + ], + } + ) + + +@pytest.mark.asyncio +async def test_gpt_streaming_emits_completed_tool_call_and_done(): + request = make_request() + + with patch.object(linux, "get_or_load_model", return_value=FakeRunner()): + chunks = [ + chunk async for chunk in linux.generate_response_chat_stream(request) + ] + + stream = "".join(chunks) + events = [ + json.loads(block.splitlines()[1].removeprefix("data: ")) + for block in stream.split("\n\n") + if block.startswith("event: ") + ] + done_event = next( + chunk + for chunk in chunks + if chunk.startswith("event: response.function_call_arguments.done") + ) + done_payload = json.loads(done_event.splitlines()[1].removeprefix("data: ")) + + assert done_payload["name"] == "read" + assert json.loads(done_payload["arguments"]) == {"path": "changelog.md"} + assert "event: response.output_item.done\n" in stream + assert chunks[-1] == "data: [DONE]\n\n" + + function_call_items = [ + event["item"] + for event in events + if event["type"] in {"response.output_item.added", "response.output_item.done"} + and event["item"]["type"] == "function_call" + ] + assert len(function_call_items) == 2 + assert function_call_items[0]["name"] == "read" + assert function_call_items[0]["arguments"] == "" + assert function_call_items[0]["call_id"] == function_call_items[1]["call_id"] + + +@pytest.mark.asyncio +async def test_gpt_streaming_infers_tool_when_commentary_has_no_recipient(): + request = make_request() + runner = FakeRunner() + runner.tool_name = "" + + with patch.object(linux, "get_or_load_model", return_value=runner): + chunks = [ + chunk async for chunk in linux.generate_response_chat_stream(request) + ] + + stream = "".join(chunks) + events = [ + json.loads(block.splitlines()[1].removeprefix("data: ")) + for block in stream.split("\n\n") + if block.startswith("event: ") + ] + function_call_added = next( + event + for event in events + if event["type"] == "response.output_item.added" + and event["item"]["type"] == "function_call" + ) + + assert function_call_added["item"]["name"] == "read" + assert "event: response.function_call_arguments.delta\n" in stream + assert "event: response.failed\n" not in stream diff --git a/server/tests/test_llama_cpp_runner.py b/server/tests/test_llama_cpp_runner.py new file mode 100644 index 0000000..8a3845a --- /dev/null +++ b/server/tests/test_llama_cpp_runner.py @@ -0,0 +1,82 @@ +import pytest +from openai_harmony import ( + Conversation, + HarmonyEncodingName, + Message, + Role, + load_harmony_encoding, +) + +from server.backend.llama_cpp_runner import LlamaRunner +from server.schemas import ToolCallStart + + +ENCODING = load_harmony_encoding(HarmonyEncodingName.HARMONY_GPT_OSS) + + +class FakeModel: + def __init__(self, completion): + self.completion = completion + self.was_reset = False + + def generate(self, prompt_tokens, **kwargs): + yield from ENCODING.encode(self.completion, allowed_special="all") + + def reset(self): + self.was_reset = True + + +def collect_completion(completion): + runner = LlamaRunner("/tmp/gpt-oss") + runner.model = FakeModel(completion) + runner._context_length = 8192 + + return list( + runner.generate_streaming_gpt( + Conversation.from_messages([]), max_tokens=128 + ) + ) + + +def test_gpt_streaming_emits_tool_call_without_answer_marker(): + chunks = collect_completion( + "<|channel|>analysis<|message|>Need to read file.<|end|>" + "<|start|>assistant<|channel|>commentary to=read <|constrain|>json" + '<|message|>{"path":"changelog.md"}<|call|>' + ) + + assert ToolCallStart("read") in chunks + assert "".join(chunk for chunk in chunks if isinstance(chunk, str)) == ( + "**[Reasoning]**\n\n" + "Need to read file." + '{"path":"changelog.md"}' + ) + + +def test_gpt_streaming_emits_final_answer_marker(): + chunks = collect_completion( + "<|channel|>analysis<|message|>Say hello.<|end|>" + "<|start|>assistant<|channel|>final<|message|>Hello.<|return|>" + ) + + assert "".join(chunk for chunk in chunks if isinstance(chunk, str)) == ( + "**[Reasoning]**\n\n" + "Say hello." + "\n---\n**[Answer]**\n\n" + "Hello." + ) + + +def test_gpt_streaming_rejects_prompt_that_exceeds_context_and_resets_model(): + runner = LlamaRunner("/tmp/gpt-oss") + model = FakeModel("") + runner.model = model + runner._context_length = 64 + conversation = Conversation.from_messages( + [Message.from_role_and_content(Role.USER, "word " * 200)] + ) + + with pytest.raises(ValueError, match="Start a new session"): + list(runner.generate_streaming_gpt(conversation, max_tokens=16)) + + assert model.was_reset diff --git a/server/uv.lock b/server/uv.lock index ae34909..b3ce9c0 100644 --- a/server/uv.lock +++ b/server/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = "==3.13.*" [options] -exclude-newer = "2026-04-05T09:43:13.300458Z" +exclude-newer = "2026-06-02T14:14:30Z" [[package]] name = "annotated-doc" @@ -240,7 +240,7 @@ wheels = [ [[package]] name = "llama-cpp-python" -version = "0.3.20" +version = "0.3.25" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "diskcache" }, @@ -248,7 +248,7 @@ dependencies = [ { name = "numpy" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/45/95/c69c47c9c8dda97f712f5864688d13a22b0159aa9adae91a69067a728532/llama_cpp_python-0.3.20.tar.gz", hash = "sha256:70f01b7d915d31c617dc66610a332cb2d51cde84c8b152d09a352206323616f5", size = 59323017, upload-time = "2026-04-03T06:56:32.455Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/fc/b488e00ebbca5c6a0b2303fb8ce3ded39a5f2293f7da3d7dfb25bcbb1bd9/llama_cpp_python-0.3.25.tar.gz", hash = "sha256:b901792eb474b3c83b07d2f6ed1668257b80133943c1fa57386d636103a66163", size = 67938860, upload-time = "2026-06-02T04:50:40.086Z" } [[package]] name = "markdown-it-py"