From 80f5a6ce2cefef00df22a53caddc1d0bab5d8154 Mon Sep 17 00:00:00 2001 From: jay Date: Mon, 21 Sep 2026 19:03:16 -0700 Subject: [PATCH 1/4] formatting --- oli_bot/api/__init__.py | 2 +- oli_bot/api/__main__.py | 2 +- oli_bot/api/app.py | 2 +- oli_bot/api/constants.py | 2 +- oli_bot/api/convert.py | 2 +- oli_bot/api/deps.py | 2 +- oli_bot/api/errors.py | 2 +- oli_bot/api/harness.py | 6 +-- oli_bot/api/routers/__init__.py | 2 +- oli_bot/api/routers/chat.py | 2 +- oli_bot/api/routers/config.py | 2 +- oli_bot/api/routers/health.py | 2 +- oli_bot/api/routers/mcp.py | 6 +-- oli_bot/api/routers/sessions.py | 2 +- oli_bot/api/routers/workspace.py | 14 ++----- oli_bot/api/routers/ws.py | 10 ++--- oli_bot/api/runner.py | 2 +- oli_bot/api/schemas.py | 2 +- oli_bot/api/session_service.py | 2 +- oli_bot/api_server.py | 2 +- oli_bot/tools/web.py | 8 +++- tests/integration/conftest.py | 23 ++++------- .../integration/test_agent_tool_loop_wire.py | 38 +++++++++++++++---- tests/integration/test_api_server_process.py | 6 ++- tests/integration/test_backend_ollama_wire.py | 10 +++-- tests/integration/test_backend_openai_wire.py | 27 +++++++++---- tests/integration/test_mcp_end_to_end.py | 20 +++++++--- tests/unit/test_agent_scaffolding.py | 3 +- tests/unit/test_mcp_client.py | 12 ++---- tests/unit/test_security.py | 2 +- tests/unit/test_web_search_tools.py | 8 +--- 31 files changed, 125 insertions(+), 100 deletions(-) diff --git a/oli_bot/api/__init__.py b/oli_bot/api/__init__.py index e095353..dec60c0 100644 --- a/oli_bot/api/__init__.py +++ b/oli_bot/api/__init__.py @@ -4,4 +4,4 @@ Importing this package has no side effects: the FastAPI app is built lazily via ``create_app()`` and agent/backend/MCP state is only constructed by ``init_state`` (called from the lifespan and from ``main()``). -""" \ No newline at end of file +""" diff --git a/oli_bot/api/__main__.py b/oli_bot/api/__main__.py index 97cc5fc..98a5b2b 100644 --- a/oli_bot/api/__main__.py +++ b/oli_bot/api/__main__.py @@ -87,4 +87,4 @@ def main() -> None: if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/oli_bot/api/app.py b/oli_bot/api/app.py index a72a28b..6a4dd08 100644 --- a/oli_bot/api/app.py +++ b/oli_bot/api/app.py @@ -84,4 +84,4 @@ def create_app() -> FastAPI: workspace.router, ): app.include_router(router) - return app \ No newline at end of file + return app diff --git a/oli_bot/api/constants.py b/oli_bot/api/constants.py index 6346c6a..36af2cf 100644 --- a/oli_bot/api/constants.py +++ b/oli_bot/api/constants.py @@ -2,4 +2,4 @@ # Sessions are namespaced per "server". The browser shares the TUI's store by # using the same default namespace the TUI falls back to when no server is set. -SESSION_SERVER = "default" \ No newline at end of file +SESSION_SERVER = "default" diff --git a/oli_bot/api/convert.py b/oli_bot/api/convert.py index 70bde82..2aeb657 100644 --- a/oli_bot/api/convert.py +++ b/oli_bot/api/convert.py @@ -154,4 +154,4 @@ def _event_to_frame(event: "AgentEvent") -> Dict[str, Any]: }, } logger.warning("Unknown agent event in websocket relay: %r", event) - return {"type": "unknown", "data": {"event": repr(event)}} \ No newline at end of file + return {"type": "unknown", "data": {"event": repr(event)}} diff --git a/oli_bot/api/deps.py b/oli_bot/api/deps.py index 372a3ce..8b06587 100644 --- a/oli_bot/api/deps.py +++ b/oli_bot/api/deps.py @@ -57,4 +57,4 @@ def get_ws_agent(websocket: WebSocket) -> Agent: def get_ws_lock(websocket: WebSocket) -> Any: - return websocket.app.state.lock \ No newline at end of file + return websocket.app.state.lock diff --git a/oli_bot/api/errors.py b/oli_bot/api/errors.py index 44b3d7c..32110c3 100644 --- a/oli_bot/api/errors.py +++ b/oli_bot/api/errors.py @@ -46,4 +46,4 @@ async def _http_error_handler(request: Request, exc: HTTPException) -> JSONRespo def register_exception_handlers(app: FastAPI) -> None: app.add_exception_handler(AgentError, _agent_error_handler) - app.add_exception_handler(HTTPException, _http_error_handler) \ No newline at end of file + app.add_exception_handler(HTTPException, _http_error_handler) diff --git a/oli_bot/api/harness.py b/oli_bot/api/harness.py index 2f2bd79..eac0369 100644 --- a/oli_bot/api/harness.py +++ b/oli_bot/api/harness.py @@ -112,9 +112,7 @@ async def _dispatch_tasks(tasks: List[dict]) -> str: return "Error: dispatch called with no tasks" available_tools = await agent.mcp_manager.get_available_tools() - sub_tools = [ - t for t in available_tools if t.get("name") != "builtin__dispatch" - ] + sub_tools = [t for t in available_tools if t.get("name") != "builtin__dispatch"] now = datetime.now(timezone.utc).isoformat() runs: List[SubAgentRun] = [] @@ -137,4 +135,4 @@ async def _dispatch_tasks(tasks: List[dict]) -> str: event_sink=agent.mcp_manager.sub_agent_queue, ) - return _dispatch_tasks \ No newline at end of file + return _dispatch_tasks diff --git a/oli_bot/api/routers/__init__.py b/oli_bot/api/routers/__init__.py index fb60a0f..fbb31b6 100644 --- a/oli_bot/api/routers/__init__.py +++ b/oli_bot/api/routers/__init__.py @@ -1 +1 @@ -"""FastAPI routers for the API server package.""" \ No newline at end of file +"""FastAPI routers for the API server package.""" diff --git a/oli_bot/api/routers/chat.py b/oli_bot/api/routers/chat.py index 1dba5f1..7af8fcd 100644 --- a/oli_bot/api/routers/chat.py +++ b/oli_bot/api/routers/chat.py @@ -56,4 +56,4 @@ async def chat_completions( } ], "usage": _usage(completion_text), - } \ No newline at end of file + } diff --git a/oli_bot/api/routers/config.py b/oli_bot/api/routers/config.py index bf68589..bceca08 100644 --- a/oli_bot/api/routers/config.py +++ b/oli_bot/api/routers/config.py @@ -112,4 +112,4 @@ async def update_config(request: Request, flat: Dict[str, Any]) -> Any: raise HTTPException(status_code=422, detail=f"Invalid config: {e}") manager.save(settings) request.app.state.config = config - return _nested_to_flat(settings) \ No newline at end of file + return _nested_to_flat(settings) diff --git a/oli_bot/api/routers/health.py b/oli_bot/api/routers/health.py index f266a47..cdae82e 100644 --- a/oli_bot/api/routers/health.py +++ b/oli_bot/api/routers/health.py @@ -9,4 +9,4 @@ @router.get("/health") async def health() -> Dict[str, str]: - return {"status": "ok"} \ No newline at end of file + return {"status": "ok"} diff --git a/oli_bot/api/routers/mcp.py b/oli_bot/api/routers/mcp.py index 22ccd0c..b1a6846 100644 --- a/oli_bot/api/routers/mcp.py +++ b/oli_bot/api/routers/mcp.py @@ -14,9 +14,7 @@ def _mcp_list(agent: Agent) -> List[Dict[str, Any]]: """Snapshot the current MCP server configs as a JSON-safe list.""" - return [ - dataclasses.asdict(cfg) for cfg in agent.mcp_manager.list_servers() - ] + return [dataclasses.asdict(cfg) for cfg in agent.mcp_manager.list_servers()] def _validate_mcp_config(cfg: MCPServerConfig) -> Optional[str]: @@ -98,4 +96,4 @@ async def remove_mcp_server( agent.mcp_manager.remove_server(name) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) - return _mcp_list(agent) \ No newline at end of file + return _mcp_list(agent) diff --git a/oli_bot/api/routers/sessions.py b/oli_bot/api/routers/sessions.py index 50e7fd5..5156cce 100644 --- a/oli_bot/api/routers/sessions.py +++ b/oli_bot/api/routers/sessions.py @@ -69,4 +69,4 @@ async def delete_session( """Delete a session, returning 404 when it does not exist.""" if not store.delete_session(SESSION_SERVER, session_id): raise HTTPException(status_code=404, detail="Session not found") - return {"deleted": session_id} \ No newline at end of file + return {"deleted": session_id} diff --git a/oli_bot/api/routers/workspace.py b/oli_bot/api/routers/workspace.py index 24d7329..9db6ac0 100644 --- a/oli_bot/api/routers/workspace.py +++ b/oli_bot/api/routers/workspace.py @@ -75,13 +75,9 @@ async def set_workspace( try: path = Path(path_str).expanduser().resolve() except (OSError, RuntimeError): - raise HTTPException( - status_code=422, detail=f"Invalid path: {path_str}" - ) + raise HTTPException(status_code=422, detail=f"Invalid path: {path_str}") if not path.is_dir(): - raise HTTPException( - status_code=422, detail=f"Not a valid directory: {path}" - ) + raise HTTPException(status_code=422, detail=f"Not a valid directory: {path}") session = agent._session session.workspace = path session._session_grants.clear() @@ -117,11 +113,9 @@ async def list_fs_directory(path: str = "/") -> Any: try: entries = _list_dir(resolved) except PermissionError: - raise HTTPException( - status_code=403, detail=f"Permission denied: {resolved}" - ) + raise HTTPException(status_code=403, detail=f"Permission denied: {resolved}") return { "path": str(resolved), "sensitive": is_sensitive_path(resolved), "entries": entries, - } \ No newline at end of file + } diff --git a/oli_bot/api/routers/ws.py b/oli_bot/api/routers/ws.py index b446618..5491145 100644 --- a/oli_bot/api/routers/ws.py +++ b/oli_bot/api/routers/ws.py @@ -68,9 +68,7 @@ async def websocket_chat(websocket: WebSocket) -> None: store, agent, connection_session_id, [], 0, False ) except Exception as e: - logger.warning( - "Failed to clear persisted session: %s", e - ) + logger.warning("Failed to clear persisted session: %s", e) new_id = connection_session_id if new_id != connection_session_id: connection_session_id = new_id @@ -130,9 +128,7 @@ async def websocket_chat(websocket: WebSocket) -> None: if isinstance(event, Done): if event.full_text: messages.append( - Message( - role="assistant", content=event.full_text - ) + Message(role="assistant", content=event.full_text) ) if requested_session and store is not None: try: @@ -163,4 +159,4 @@ async def websocket_chat(websocket: WebSocket) -> None: {"type": "error", "data": {"message": str(e)}} ) except WebSocketDisconnect: - logger.debug("WebSocket client disconnected from /v1/chat") \ No newline at end of file + logger.debug("WebSocket client disconnected from /v1/chat") diff --git a/oli_bot/api/runner.py b/oli_bot/api/runner.py index 0a5bd75..6f845ae 100644 --- a/oli_bot/api/runner.py +++ b/oli_bot/api/runner.py @@ -142,4 +142,4 @@ def chunk(delta: Dict[str, Any], finish_reason: Any = None) -> str: yield "data: " + json.dumps({"error": error}) + "\n\n" else: yield chunk({}, finish_reason="stop") - yield "data: [DONE]\n\n" \ No newline at end of file + yield "data: [DONE]\n\n" diff --git a/oli_bot/api/schemas.py b/oli_bot/api/schemas.py index 250c92d..8f83582 100644 --- a/oli_bot/api/schemas.py +++ b/oli_bot/api/schemas.py @@ -12,4 +12,4 @@ class RenameSessionBody(BaseModel): class SetWorkspaceBody(BaseModel): - path: str = "" \ No newline at end of file + path: str = "" diff --git a/oli_bot/api/session_service.py b/oli_bot/api/session_service.py index 442628e..e48b4ff 100644 --- a/oli_bot/api/session_service.py +++ b/oli_bot/api/session_service.py @@ -75,4 +75,4 @@ def _load_conversation( messages = sanitize_tool_history( [_message_from_dict(m) for m in data.get("messages", [])] ) - return session_id, messages \ No newline at end of file + return session_id, messages diff --git a/oli_bot/api_server.py b/oli_bot/api_server.py index 65d5af1..888fa8e 100644 --- a/oli_bot/api_server.py +++ b/oli_bot/api_server.py @@ -100,4 +100,4 @@ def main() -> None: if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/oli_bot/tools/web.py b/oli_bot/tools/web.py index 32e3041..3d74604 100644 --- a/oli_bot/tools/web.py +++ b/oli_bot/tools/web.py @@ -5,6 +5,7 @@ import logging import random import re +import ssl import socket from pathlib import Path from urllib.parse import urljoin, urlparse @@ -41,6 +42,11 @@ _ALLOWED_URL_SCHEMES: frozenset[str] = frozenset({"http", "https"}) _MAX_REDIRECTS = 5 +# Disable SSL verification for httpx requests to avoid issues with self-signed certificates. +ctx = ssl.create_default_context() +ctx.check_hostname = False +ctx.verify_mode = ssl.CERT_NONE + async def _ssrf_safe_request( client: httpx.AsyncClient, @@ -406,7 +412,7 @@ async def _fetch_handler( headers = {"User-Agent": random.choice(_FETCH_USER_AGENTS)} try: async with httpx.AsyncClient( - timeout=_FETCH_TIMEOUT, follow_redirects=False + timeout=_FETCH_TIMEOUT, follow_redirects=False, verify=False ) as client: response, ssrf_err = await _ssrf_safe_request( client, "get", url, headers=headers diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 8d06329..2621373 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -151,9 +151,7 @@ def __init__(self, *, route_prefix="/v1"): self.handler = None self.route_prefix = route_prefix self.app = FastAPI() - self.app.add_api_route( - f"{route_prefix}/models", self._models, methods=["GET"] - ) + self.app.add_api_route(f"{route_prefix}/models", self._models, methods=["GET"]) self.app.add_api_route( f"{route_prefix}/chat/completions", self._chat, methods=["POST"] ) @@ -165,9 +163,7 @@ def __init__(self, *, route_prefix="/v1"): self._start() def _start(self): - config = uvicorn.Config( - self.app, host="127.0.0.1", port=0, log_level="warning" - ) + config = uvicorn.Config(self.app, host="127.0.0.1", port=0, log_level="warning") self._server = uvicorn.Server(config) self._thread = threading.Thread(target=self._server.run, daemon=True) self._thread.start() @@ -217,6 +213,7 @@ async def gen(): return StreamingResponse(gen(), media_type="text/event-stream") if kind == "ollama": + async def ollama_gen(): for part in spec[1]: if isinstance(part, dict): @@ -293,8 +290,7 @@ def ollama_backend(mock_ollama): # Real MCP servers (official SDK in a subprocess) # # --------------------------------------------------------------------------- # -_MCP_STDIO_SERVER_SRC = textwrap.dedent( - """\ +_MCP_STDIO_SERVER_SRC = textwrap.dedent("""\ import asyncio from mcp.server.mcpserver import MCPServer @@ -318,8 +314,7 @@ def fail(message: str) -> str: raise RuntimeError(message) asyncio.run(server.run_stdio_async()) - """ -) + """) @pytest.fixture @@ -345,8 +340,7 @@ def mcp_http_script(tmp_path): """ port = _free_port() url = f"http://127.0.0.1:{port}/mcp" - src = textwrap.dedent( - f"""\ + src = textwrap.dedent(f"""\ import asyncio from mcp.server.mcpserver import MCPServer @@ -364,8 +358,7 @@ def add(a: int, b: int) -> str: asyncio.run(server.run_streamable_http_async( host="127.0.0.1", port={port}, streamable_http_path="/mcp" )) - """ - ) + """) path = tmp_path / "mock_mcp_http_server.py" path.write_text(src) proc = subprocess.Popen( @@ -472,4 +465,4 @@ def auto_allow(): async def _allow(description): return "session" - return _allow \ No newline at end of file + return _allow diff --git a/tests/integration/test_agent_tool_loop_wire.py b/tests/integration/test_agent_tool_loop_wire.py index a818ad5..c16f259 100644 --- a/tests/integration/test_agent_tool_loop_wire.py +++ b/tests/integration/test_agent_tool_loop_wire.py @@ -14,7 +14,9 @@ @pytest.mark.integration -async def test_run_command_executes_real_subprocess(make_full_agent, mock_openai, auto_allow, tmp_path): +async def test_run_command_executes_real_subprocess( + make_full_agent, mock_openai, auto_allow, tmp_path +): agent, _ = make_full_agent(workspace=None, offline_mode=False) mock_openai.script( ( @@ -32,7 +34,10 @@ async def test_run_command_executes_real_subprocess(make_full_agent, mock_openai ] } ), - cc(finish="tool_calls", usage={"prompt_tokens": 4, "completion_tokens": 2}), + cc( + finish="tool_calls", + usage={"prompt_tokens": 4, "completion_tokens": 2}, + ), ], ), ( @@ -54,7 +59,10 @@ async def test_run_command_executes_real_subprocess(make_full_agent, mock_openai assert "hello from integration" in results[0].result finals = [e.full_text for e in events if isinstance(e, Done)] assert finals == ["Command output captured."] - assert any(msg.role == "tool" and "hello from integration" in msg.content for msg in messages) + assert any( + msg.role == "tool" and "hello from integration" in msg.content + for msg in messages + ) @pytest.mark.integration @@ -81,7 +89,10 @@ async def test_git_readonly_allowed_push_denied( ] } ), - cc(finish="tool_calls", usage={"prompt_tokens": 3, "completion_tokens": 2}), + cc( + finish="tool_calls", + usage={"prompt_tokens": 3, "completion_tokens": 2}, + ), ], ), ( @@ -99,7 +110,10 @@ async def test_git_readonly_allowed_push_denied( ] } ), - cc(finish="tool_calls", usage={"prompt_tokens": 3, "completion_tokens": 2}), + cc( + finish="tool_calls", + usage={"prompt_tokens": 3, "completion_tokens": 2}, + ), ], ), ( @@ -145,7 +159,10 @@ async def test_write_then_read_file_via_tools( ] } ), - cc(finish="tool_calls", usage={"prompt_tokens": 4, "completion_tokens": 2}), + cc( + finish="tool_calls", + usage={"prompt_tokens": 4, "completion_tokens": 2}, + ), ], ), ( @@ -163,7 +180,10 @@ async def test_write_then_read_file_via_tools( ] } ), - cc(finish="tool_calls", usage={"prompt_tokens": 4, "completion_tokens": 2}), + cc( + finish="tool_calls", + usage={"prompt_tokens": 4, "completion_tokens": 2}, + ), ], ), ( @@ -183,4 +203,6 @@ async def test_write_then_read_file_via_tools( assert len(results) == 2 assert target.exists() assert "hello world" in results[1] - assert any(e.full_text == "Wrote and read back." for e in events if isinstance(e, Done)) \ No newline at end of file + assert any( + e.full_text == "Wrote and read back." for e in events if isinstance(e, Done) + ) diff --git a/tests/integration/test_api_server_process.py b/tests/integration/test_api_server_process.py index 29dcde9..ddf551c 100644 --- a/tests/integration/test_api_server_process.py +++ b/tests/integration/test_api_server_process.py @@ -88,7 +88,9 @@ def test_server_boots_and_streams_chat_completion(server_env, tmp_path): ) assert resp.status_code == 200, resp.text data = resp.json() - assert data["choices"][0]["message"]["content"] == "Hello from the mocked backend." + assert ( + data["choices"][0]["message"]["content"] == "Hello from the mocked backend." + ) assert mock.calls # the chat completion really went out over the wire models = httpx.get(f"http://127.0.0.1:{port}/v1/models", timeout=15) @@ -118,4 +120,4 @@ def _wait_healthy(port, timeout=25.0): except Exception as e: last = e time.sleep(0.25) - raise AssertionError(f"oli-server at :{port} never came up healthy: {last}") \ No newline at end of file + raise AssertionError(f"oli-server at :{port} never came up healthy: {last}") diff --git a/tests/integration/test_backend_ollama_wire.py b/tests/integration/test_backend_ollama_wire.py index ef748e0..cf4d576 100644 --- a/tests/integration/test_backend_ollama_wire.py +++ b/tests/integration/test_backend_ollama_wire.py @@ -31,7 +31,9 @@ async def test_streams_text_over_real_wire(ollama_backend, mock_ollama): [ ollama_part(content="Hello "), ollama_part(content="world"), - ollama_part(done=True, counts={"prompt_eval_count": 9, "eval_count": 4}), + ollama_part( + done=True, counts={"prompt_eval_count": 9, "eval_count": 4} + ), ], ) ) @@ -54,7 +56,9 @@ async def test_tool_calls_round_trip_over_wire(ollama_backend, mock_ollama): ollama_part( tool_calls=[ollama_tool("run_command", {"command": "pwd"})] ), - ollama_part(done=True, counts={"prompt_eval_count": 3, "eval_count": 2}), + ollama_part( + done=True, counts={"prompt_eval_count": 3, "eval_count": 2} + ), ], ) ) @@ -107,4 +111,4 @@ async def test_usage_falls_back_to_estimate_when_counts_absent( ): mock_ollama.script(("ollama", [ollama_part(content="x"), ollama_part(done=True)])) events = await _run(ollama_backend) - assert events[-1].usage.estimated is True \ No newline at end of file + assert events[-1].usage.estimated is True diff --git a/tests/integration/test_backend_openai_wire.py b/tests/integration/test_backend_openai_wire.py index 7613739..8c74d97 100644 --- a/tests/integration/test_backend_openai_wire.py +++ b/tests/integration/test_backend_openai_wire.py @@ -79,9 +79,7 @@ async def test_reasoning_content_becomes_thinking_chunk(openai_backend, mock_ope @pytest.mark.integration -async def test_tool_call_fragments_round_trip_over_wire( - openai_backend, mock_openai -): +async def test_tool_call_fragments_round_trip_over_wire(openai_backend, mock_openai): """Argument JSON split across SSE deltas must be reassembled server-side.""" mock_openai.script( ( @@ -90,7 +88,9 @@ async def test_tool_call_fragments_round_trip_over_wire( cc( delta={ "tool_calls": [ - tool_delta(0, tc_id="c1", name="run_command", args='{"command"'), + tool_delta( + 0, tc_id="c1", name="run_command", args='{"command"' + ), ] } ), @@ -120,7 +120,14 @@ async def test_parallel_tool_calls_by_index_over_wire(openai_backend, mock_opena ] } ), - cc(delta={"tool_calls": [tool_delta(0, args="1}"), tool_delta(1, args="2}")]}), + cc( + delta={ + "tool_calls": [ + tool_delta(0, args="1}"), + tool_delta(1, args="2}"), + ] + } + ), cc(finish="tool_calls"), ], ) @@ -139,7 +146,13 @@ async def test_flush_on_stop_finish_reason_over_wire(openai_backend, mock_openai ( "stream", [ - cc(delta={"tool_calls": [tool_delta(0, tc_id="c1", name="do", args='{"a": 1}')]}), + cc( + delta={ + "tool_calls": [ + tool_delta(0, tc_id="c1", name="do", args='{"a": 1}') + ] + } + ), cc(finish="stop"), ], ) @@ -209,4 +222,4 @@ async def test_list_models_over_wire(mock_openai): client = AsyncOpenAI(api_key="x", base_url=f"{mock_openai.base_url}/v1") models = await client.models.list() ids = [m.id for m in models.data] - assert "mocked" in ids \ No newline at end of file + assert "mocked" in ids diff --git a/tests/integration/test_mcp_end_to_end.py b/tests/integration/test_mcp_end_to_end.py index 5ea437d..344241f 100644 --- a/tests/integration/test_mcp_end_to_end.py +++ b/tests/integration/test_mcp_end_to_end.py @@ -51,7 +51,9 @@ async def test_stdio_structured_content_surfaces(mcp_manager_factory, mcp_stdio_ @pytest.mark.integration -async def test_stdio_tool_error_surfaces_as_error(mcp_manager_factory, mcp_stdio_script): +async def test_stdio_tool_error_surfaces_as_error( + mcp_manager_factory, mcp_stdio_script +): m = await _make_stdio_manager(mcp_manager_factory, mcp_stdio_script) result = await m.call_tool("mock__fail", {"message": "boom"}) assert result.startswith("Error:") @@ -79,12 +81,18 @@ async def test_agent_calls_real_mcp_tool_over_wire( delta={ "tool_calls": [ tool_delta( - 0, tc_id="t1", name="mock__add", args='{"a": 200, "b": 22}' + 0, + tc_id="t1", + name="mock__add", + args='{"a": 200, "b": 22}', ) ] } ), - cc(finish="tool_calls", usage={"prompt_tokens": 4, "completion_tokens": 2}), + cc( + finish="tool_calls", + usage={"prompt_tokens": 4, "completion_tokens": 2}, + ), ], ), ( @@ -103,7 +111,9 @@ async def test_agent_calls_real_mcp_tool_over_wire( events = [ev async for ev in agent.process(messages, confirm_callback=auto_allow)] finals = [e.full_text for e in events if isinstance(e, Done)] assert finals == ["The sum is 222."] - assert any(isinstance(e, StreamChunk) and e.text.startswith("The sum") for e in events) + assert any( + isinstance(e, StreamChunk) and e.text.startswith("The sum") for e in events + ) # The tool round-trip really hit the subprocess: the model's tool call was # executed against the real stdio MCP server and the result was relayed # back into the conversation as role= tool before the final reply. @@ -137,4 +147,4 @@ async def test_http_mcp_blocked_by_offline_mode(tmp_path, mcp_http_script): ) m.add_server("httpsrv", transport="http", url=mcp_http_script["url"]) result = await m.call_tool("httpsrv__ping", {}) - assert "offline" in result.lower() \ No newline at end of file + assert "offline" in result.lower() diff --git a/tests/unit/test_agent_scaffolding.py b/tests/unit/test_agent_scaffolding.py index 96398f9..e6e8303 100644 --- a/tests/unit/test_agent_scaffolding.py +++ b/tests/unit/test_agent_scaffolding.py @@ -391,8 +391,7 @@ def test_agent_pool_warns_on_duplicate_agent_name(monkeypatch, tmp_path, caplog) assert pool.select_agent("default", "clash").backend.model == "m-two" # ...but the collision is surfaced in the logs instead of being silent. assert any( - record.name == "oli_bot.agent" - and "Duplicate agent name" in record.getMessage() + record.name == "oli_bot.agent" and "Duplicate agent name" in record.getMessage() for record in caplog.records ) diff --git a/tests/unit/test_mcp_client.py b/tests/unit/test_mcp_client.py index 3b8e380..211ef8c 100644 --- a/tests/unit/test_mcp_client.py +++ b/tests/unit/test_mcp_client.py @@ -417,16 +417,12 @@ def test_offline_mode_blocks_http_transport_but_not_stdio(tmp_path, monkeypatch) }, ) - http_decision = gate._evaluate_permission( - "http-srv__tool", {}, skip_session=True - ) + http_decision = gate._evaluate_permission("http-srv__tool", {}, skip_session=True) assert http_decision.outcome == "deny" assert http_decision.source == "offline" # A local stdio server must remain callable under the default offline config. - stdio_decision = gate._evaluate_permission( - "stdio-srv__tool", {}, skip_session=True - ) + stdio_decision = gate._evaluate_permission("stdio-srv__tool", {}, skip_session=True) assert stdio_decision.outcome == "allow" @@ -452,8 +448,6 @@ def test_dry_run_returns_preview_for_mcp_tool(tmp_path, monkeypatch): config=AppConfig(_env_file=None, offline_mode=False, dry_run=True), mcp_servers={"srv": SimpleNamespace(transport="stdio")}, ) - decision = gate._evaluate_permission( - "srv__tool", {"a": 1}, skip_session=True - ) + decision = gate._evaluate_permission("srv__tool", {"a": 1}, skip_session=True) assert decision.outcome == "preview" assert "DRY RUN" in (decision.preview or "") diff --git a/tests/unit/test_security.py b/tests/unit/test_security.py index bdf3305..74c024c 100644 --- a/tests/unit/test_security.py +++ b/tests/unit/test_security.py @@ -435,7 +435,7 @@ def test_shell_rejects_double_quoted_command_substitution(): def test_shell_rejects_double_quoted_backtick_substitution(): - err = _is_command_allowed("echo \"`id`\"") + err = _is_command_allowed('echo "`id`"') assert err is not None assert "Shell metacharacters" in err diff --git a/tests/unit/test_web_search_tools.py b/tests/unit/test_web_search_tools.py index 27280d5..a7433c1 100644 --- a/tests/unit/test_web_search_tools.py +++ b/tests/unit/test_web_search_tools.py @@ -136,9 +136,7 @@ async def test_ssrf_redirect_budget_is_bounded(): def resp(request): hops["n"] += 1 - return httpx.Response( - 302, headers={"location": "/loop"}, request=request - ) + return httpx.Response(302, headers={"location": "/loop"}, request=request) async with _redirect_client(resp) as client: response, err = await _ssrf_safe_request( @@ -154,9 +152,7 @@ async def test_ssrf_safe_relative_redirect_is_still_followed(): # Legitimate same-host relative redirects must keep working. def resp(request): if request.url.path == "/start": - return httpx.Response( - 302, headers={"location": "/finish"}, request=request - ) + return httpx.Response(302, headers={"location": "/finish"}, request=request) return httpx.Response(200, text="ok", request=request) async with _redirect_client(resp) as client: From bd74c54529d2f315818f35d029589df0eed0bc3b Mon Sep 17 00:00:00 2001 From: jay Date: Mon, 21 Sep 2026 19:05:32 -0700 Subject: [PATCH 2/4] ssl updates --- oli_bot/tools/web.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/oli_bot/tools/web.py b/oli_bot/tools/web.py index 3d74604..afcb275 100644 --- a/oli_bot/tools/web.py +++ b/oli_bot/tools/web.py @@ -412,7 +412,7 @@ async def _fetch_handler( headers = {"User-Agent": random.choice(_FETCH_USER_AGENTS)} try: async with httpx.AsyncClient( - timeout=_FETCH_TIMEOUT, follow_redirects=False, verify=False + timeout=_FETCH_TIMEOUT, follow_redirects=False, verify=ctx ) as client: response, ssrf_err = await _ssrf_safe_request( client, "get", url, headers=headers @@ -511,7 +511,7 @@ async def _download_file_handler(url, file_path): headers = {"User-Agent": random.choice(_FETCH_USER_AGENTS)} try: async with httpx.AsyncClient( - timeout=_FETCH_TIMEOUT, follow_redirects=False + timeout=_FETCH_TIMEOUT, follow_redirects=False, verify=ctx ) as client: response, ssrf_err = await _ssrf_safe_request( client, "get", url, headers=headers @@ -553,7 +553,9 @@ async def _upload_file_handler(url, file_path, method="PUT", field_name="file"): return f"Error: Not a file: {file_path}" try: - async with httpx.AsyncClient(timeout=60, follow_redirects=False) as client: + async with httpx.AsyncClient( + timeout=60, follow_redirects=False, verify=False + ) as client: method_l = method.lower() if method_l == "post": files = {field_name: (path.name, path.read_bytes())} @@ -665,7 +667,7 @@ def _search_github(query: str, max_results: int = 10) -> List[Dict[str, Any]]: "per_page": min(max_results, 100), } headers = {"User-Agent": random.choice(_FETCH_USER_AGENTS)} - response = httpx.get(url, params=params, headers=headers) + response = httpx.get(url, params=params, headers=headers, verify=ctx) response.raise_for_status() data = response.json() @@ -796,6 +798,7 @@ def _search_open_library_sync(query: str, max_results: int = 5) -> str: url, params={"q": query, "limit": max_results}, timeout=_FETCH_TIMEOUT, + verify=ctx, ) resp.raise_for_status() docs = resp.json().get("docs", []) From 641eb5ab831e761c13d56785c7fd83c358ef251f Mon Sep 17 00:00:00 2001 From: jay Date: Mon, 21 Sep 2026 19:44:16 -0700 Subject: [PATCH 3/4] update param --- oli_bot/tools/web.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oli_bot/tools/web.py b/oli_bot/tools/web.py index afcb275..505fc82 100644 --- a/oli_bot/tools/web.py +++ b/oli_bot/tools/web.py @@ -554,7 +554,7 @@ async def _upload_file_handler(url, file_path, method="PUT", field_name="file"): try: async with httpx.AsyncClient( - timeout=60, follow_redirects=False, verify=False + timeout=60, follow_redirects=False, verify=ctx ) as client: method_l = method.lower() if method_l == "post": From dacfa116ab07f963a78010baa4a520f1c9db5e85 Mon Sep 17 00:00:00 2001 From: jay Date: Tue, 22 Sep 2026 11:31:24 -0700 Subject: [PATCH 4/4] add responses api support to openai backend --- AGENTS.md | 2 +- docs/BACKENDS.md | 21 +- docs/CONFIGURE.md | 1 + oli_bot/backends/factory.py | 1 + oli_bot/backends/messages.py | 109 +++++ oli_bot/backends/openai.py | 226 ++++++++++- oli_bot/config.py | 3 + oli_bot/settings.py | 4 + tests/unit/test_openai_responses_backend.py | 422 ++++++++++++++++++++ 9 files changed, 778 insertions(+), 11 deletions(-) create mode 100644 tests/unit/test_openai_responses_backend.py diff --git a/AGENTS.md b/AGENTS.md index a43ca50..d2f66ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ This file is the top-level orientation map. Depth lives under [docs/](docs/). | [oli_bot/chat.py](oli_bot/chat.py) | Textual TUI app (`OliBot`). Owns command handling, session UI, the `#command-suggestions` autocomplete `ListView`, and (when pooling is enabled) the "Active Sub-Agents" `Tree`. Tracks cumulative session token usage (persisted per session) and renders it in the `#status-bar`. Slash-command names come from the module-level `COMMANDS` tuple. | | [oli_bot/api/](oli_bot/api/) | FastAPI package exposing the harness over an OpenAI-compatible REST API (`GET /v1/models`, `POST /v1/chat/completions` streaming + non-streaming, `GET /health`) plus a stateful `WS /v1/chat` WebSocket that relays every `AgentEvent` as a typed JSON envelope (`text_chunk`/`thinking`/`tool_call_executing`/`tool_call_result`/`assistant_response`/`usage`/`error`/`done`, plus `sub_agent_started`/`sub_agent_progress`/`sub_agent_completed` and `todo` frames) for real-time browser UIs. Sub-agent events are demuxed by `task_id`; `_wire_todo_relay()` pushes `builtin__todowrite` snapshots onto `mcp_manager.pending_todos`, drained by the socket loop. REST is stateless from the caller's POV; the WebSocket keeps per-connection history, backed by a server-persisted `ConversationStore` under the `"default"` server: turns are sent with an optional `session_id` (`{"content": "...", "session_id": "..."}`, `{"action": "clear", "session_id": "..."}` wipes it) and each completed turn is saved to disk, so browser and TUI share the same session files. Sessions are created/listed/loaded/renamed/deleted via `GET/POST /v1/sessions` and `GET/PUT/DELETE /v1/sessions/{id}`. A single process-private `Agent` is shared across requests and serialised with an `asyncio.Lock` held across `await` points (a `threading.RLock` would be per-thread reentrant and not serialize coroutines sharing the event loop); the WebSocket holds the lock for the duration of each run. Auto-approves permissions (no human), but offline/dry-run still apply. Also exposes `GET/POST /v1/mcp` plus `PUT/DELETE /v1/mcp/{name}` for the browser UI's MCP server configuration view (backed by `MCPClientManager` on `app.state.agent.mcp_manager`). `app.py` holds the `create_app()` factory + `init_state()`; `routers/` holds the route modules (`chat`, `ws`, `sessions`, `config`, `mcp`, `workspace`, `health`); `runner.py` implements the completion handoff, `harness.py` the agent construction + todo relay + `dispatch` handler, `session_service.py` the load/persist helpers, `errors.py` the OpenAI-style error handlers, `convert.py` the wire-message/event converters, `deps.py` the FastAPI dependencies, and `__main__.py` the `oli-server` entrypoint. [`oli_bot/api_server.py`](oli_bot/api_server.py) is a compat shim re-exporting the old single-module surface. See [docs/API_SERVER.md](docs/API_SERVER.md). | | [oli_bot/agent.py](oli_bot/agent.py) | `Agent` — mode + system prompt owner; orchestrates the tool-calling loop and streams typed events (`TextChunk`, `ThinkingChunk`, `ToolCallChunk`, `ToolCallExecuting`, `ToolCallResult`, `StreamChunk`, `UsageEvent`, `Error`, `Done`). Aggregates per-call `UsageChunk`s from each backend round into a single per-run `UsageEvent`. Also hosts `sanitize_tool_history`, `_merge_usage`, `stream_sub_agent_run`, and `AgentPool` (built from `agents.yaml` when `--use-pool` is set; located via `$OLI_AGENTS_YAML`, the package dir, the repo root, the current working directory, or `~/.config/oli`). | -| [oli_bot/backends/](oli_bot/backends/) | Backend package — `ModelBackend` ABC, `OllamaBackend`, `OpenAIBackend`, `HuggingFaceBackend`, `TransformersBackend`, and the `create_model_backend()` factory. Also hosts the shared `_StreamingThinkParser` and per-backend message formatting (Ollama native `images`, OpenAI `image_url` or Bedrock-native blocks via `openai_vision_style`, textual placeholder for text-only backends). Every backend surfaces a trailing `UsageChunk`: exact counts from provider usage where available (OpenAI `usage`/`stream_options`, Ollama `prompt_eval_count`/`eval_count`, HF `usage`), else a `~chars/4` estimate via `estimate_tokens`. See [docs/BACKENDS.md](docs/BACKENDS.md). | +| [oli_bot/backends/](oli_bot/backends/) | Backend package — `ModelBackend` ABC, `OllamaBackend`, `OpenAIBackend`, `HuggingFaceBackend`, `TransformersBackend`, and the `create_model_backend()` factory. `OpenAIBackend` speaks both Chat Completions (default) and the Responses API, selected by the `OLI_OPENAI_RESPONSES_ENABLED` / `openai_responses_enabled` flag routed through `generate()` / `stream_generate()`. Also hosts the shared `_StreamingThinkParser` and per-backend message formatting (Ollama native `images`, OpenAI `image_url` or Bedrock-native blocks via `openai_vision_style`, Responses `input_text`/`input_image` items, textual placeholder for text-only backends). Every backend surfaces a trailing `UsageChunk`: exact counts from provider usage where available (OpenAI `usage`/`stream_options`, Responses `input_tokens`/`output_tokens`, Ollama `prompt_eval_count`/`eval_count`, HF `usage`), else a `~chars/4` estimate via `estimate_tokens`. See [docs/BACKENDS.md](docs/BACKENDS.md). | | [oli_bot/screens/](oli_bot/screens/) | All `ModalScreen` subclasses: `PermissionScreen`, `ConfirmScreen`, `ModelPickerScreen`, `ServerListScreen`, `MCPSetupScreen`, `SessionListScreen`, `WorkspaceListScreen`, `SubAgentViewScreen`, `ConfigScreen`, `InputPromptScreen`, plus `taglines.py` / `todo_widget.py`. | | [oli_bot/models.py](oli_bot/models.py) | Shared dataclasses: `Message`, `ToolCall`, `ModelResponse`, `HostConfig`, `MCPServerConfig`, `ProfileData`, `SubAgentRun`, `ImageAttachment`, `TodoItem` / `TodoListState`, plus `AgentEvent` variants and the `AgentRole` enum. Token accounting lives here too: `Usage` (prompt/completion/`estimated` flag), the per-call `UsageChunk` stream event, and the per-run `UsageEvent`. `Message.images` is in-memory only (dropped on session save); `ModelResponse.usage` is optionally set by backends. | | [oli_bot/config.py](oli_bot/config.py) | `AppConfig` — `pydantic_settings.BaseSettings`. Env vars prefixed `OLI_`, plus `.env` support and `OLI_TRUNCATION_SMALL` / `_LARGE` aliases via `AliasChoices`. Module-level `configs = AppConfig()` singleton. See [docs/CONFIGURE.md](docs/CONFIGURE.md). | diff --git a/docs/BACKENDS.md b/docs/BACKENDS.md index 69c8131..7c115f7 100644 --- a/docs/BACKENDS.md +++ b/docs/BACKENDS.md @@ -25,13 +25,20 @@ Multiple Ollama servers can be configured via `/servers` and persisted to `ollam Works with the OpenAI API or any compatible endpoint (Azure, local proxies, etc.). -| Setting | Default | Env var | -| --------------------- | --------------------------- | ------------------------- | -| `openai_api_key` | `""` | `OLI_OPENAI_API_KEY` | -| `openai_base_url` | `https://api.openai.com/v1` | `OLI_OPENAI_BASE_URL` | -| `openai_model` | `gpt-4o` | `OLI_OPENAI_MODEL` | -| `openai_small_model` | `gpt-4o-mini` | `OLI_OPENAI_SMALL_MODEL` | -| `openai_vision_style` | `openai` | `OLI_OPENAI_VISION_STYLE` | +| Setting | Default | Env var | +| -------------------------- | --------------------------- | ----------------------------- | +| `openai_api_key` | `""` | `OLI_OPENAI_API_KEY` | +| `openai_base_url` | `https://api.openai.com/v1` | `OLI_OPENAI_BASE_URL` | +| `openai_model` | `gpt-4o` | `OLI_OPENAI_MODEL` | +| `openai_small_model` | `gpt-4o-mini` | `OLI_OPENAI_SMALL_MODEL` | +| `openai_vision_style` | `openai` | `OLI_OPENAI_VISION_STYLE` | +| `openai_responses_enabled` | `false` | `OLI_OPENAI_RESPONSES_ENABLED` | + +By default the backend targets Chat Completions (`POST /v1/chat/completions`). Set +`openai_responses_enabled: true` (or `OLI_OPENAI_RESPONSES_ENABLED=true`) to use +the Responses API (`POST /v1/responses`) instead; tool calling, usage accounting, +and streaming are supported on both paths. Providers that only expose one of the +two wire formats must be configured with the matching flag. `openai_vision_style` controls how the `view_image` tool serializes attachments for the OpenAI backend: diff --git a/docs/CONFIGURE.md b/docs/CONFIGURE.md index 475bc98..1501b22 100644 --- a/docs/CONFIGURE.md +++ b/docs/CONFIGURE.md @@ -26,6 +26,7 @@ If `~/.config/oli/settings.json` does not exist, it is auto-created on first loa | `openai_model` | `gpt-4o` | `OLI_OPENAI_MODEL` | OpenAI large model | | `openai_small_model` | `gpt-4o-mini` | `OLI_OPENAI_SMALL_MODEL` | OpenAI small model | | `openai_vision_style` | `openai` | `OLI_OPENAI_VISION_STYLE` | Vision content-block style: `openai` (default `image_url`) or `bedrock` (Bedrock-native `image` blocks for Kong/LiteLLM proxies fronting Bedrock) | +| `openai_responses_enabled` | `false` | `OLI_OPENAI_RESPONSES_ENABLED` | Use the OpenAI Responses API (`/v1/responses`) instead of Chat Completions for the OpenAI backend | | `ollama_base_url` | `http://localhost:11434` | `OLI_OLLAMA_BASE_URL` | Ollama server URL | | `ollama_model` | `ollama` | `OLI_OLLAMA_MODEL` | Ollama large model | | `ollama_small_model` | `""` | `OLI_OLLAMA_SMALL_MODEL` | Ollama small model | diff --git a/oli_bot/backends/factory.py b/oli_bot/backends/factory.py index d0fa4e3..1f2e240 100644 --- a/oli_bot/backends/factory.py +++ b/oli_bot/backends/factory.py @@ -36,6 +36,7 @@ def create_model_backend( base_url=base_url or configs.openai_base_url, model=model or configs.openai_model, vision_style=configs.openai_vision_style, + responses_enabled=configs.openai_responses_enabled, ) elif backend_type == "ollama": diff --git a/oli_bot/backends/messages.py b/oli_bot/backends/messages.py index 1427dd6..e40212c 100644 --- a/oli_bot/backends/messages.py +++ b/oli_bot/backends/messages.py @@ -245,6 +245,113 @@ def _format_tools(tools: list[dict]) -> List[Dict[str, Any]]: return formatted +def _format_responses_messages( + messages: List[Message], + image_style: str = "openai", +) -> List[Dict[str, Any]]: + """Serialize Messages into OpenAI Responses API input items. + + Unlike the chat-completions wire format, the Responses API represents + tool activity as bare input items: each assistant tool call becomes a + ``function_call`` item and each ``role=tool`` result becomes a + ``function_call_output`` item (matched by ``call_id``). Plain + ``system``/``user``/``assistant`` messages become ``{"role", "content"}`` + items. + + ``image_style`` mirrors ``_format_messages``: ``"openai"`` rewrites + user-role content into ``input_text`` / ``input_image`` parts (the + Responses-native equivalents of the chat ``content`` parts); any other + style drops the bytes and appends a bracketed text note, since Bedrock / + Ollama image blocks are not valid in the Responses wire format. + """ + formatted: List[Dict[str, Any]] = [] + for m in messages: + content = m.content + images = m.images or None + + if m.role == "tool": + formatted.append( + { + "type": "function_call_output", + "call_id": m.tool_call_id or "", + "output": content, + } + ) + continue + + if m.role == "assistant" and m.tool_calls: + if content and content.strip(): + formatted.append({"role": "assistant", "content": content}) + for tc in m.tool_calls: + func = tc.get("function", tc) if isinstance(tc, dict) else tc + name = func.get("name", "") if isinstance(func, dict) else "" + raw_args = ( + func.get("arguments", "{}") if isinstance(func, dict) else "{}" + ) + if isinstance(raw_args, (dict, list)): + raw_args = json.dumps(raw_args) + formatted.append( + { + "type": "function_call", + "call_id": (tc.get("id", "") if isinstance(tc, dict) else ""), + "name": name, + "arguments": raw_args or "{}", + } + ) + continue + + if images and image_style == "openai" and m.role == "user": + parts: List[Dict[str, Any]] = [] + if content and content.strip(): + parts.append({"type": "input_text", "text": content.strip()}) + for att in images: + if not att.data: + logger.warning( + "Skipping image attachment with empty data in message" + ) + continue + b64 = _b64.b64encode(att.data).decode("ascii") + parts.append( + { + "type": "input_image", + "image_url": f"data:{att.media_type};base64,{b64}", + } + ) + if parts: + formatted.append({"role": m.role, "content": parts}) + continue + + if images and m.role == "user": + content = _append_image_placeholder_text(content, images) + + formatted.append({"role": m.role, "content": content}) + return formatted + + +def _format_responses_tools(tools: Optional[List[Dict]]) -> List[Dict[str, Any]]: + """Convert the flat tool list to the Responses API ``FunctionToolParam`` shape. + + Responses function tools are flat (``name``/``description``/``parameters`` + at the top level) rather than nested under a ``function`` key like the + chat-completions format. + """ + if not tools: + return [] + formatted = [] + for t in tools: + func = t.get("function", t) + params = func.get("parameters", {}) + formatted.append( + { + "type": "function", + "name": func.get("name", ""), + "description": func.get("description", ""), + "parameters": _strip_none_values(params) if params else {}, + } + ) + return formatted + + __all__ = [ "_format_messages", "_validate_message_content_blocks", @@ -252,4 +359,6 @@ def _format_tools(tools: list[dict]) -> List[Dict[str, Any]]: "_append_image_placeholder_text", "_strip_none_values", "_format_tools", + "_format_responses_messages", + "_format_responses_tools", ] diff --git a/oli_bot/backends/openai.py b/oli_bot/backends/openai.py index 01a49b8..529ce9b 100644 --- a/oli_bot/backends/openai.py +++ b/oli_bot/backends/openai.py @@ -6,6 +6,7 @@ from openai import AsyncOpenAI from openai.types.chat import ChatCompletion as OpenAIResponse +from openai.types.responses import Response as OpenAIResponsesResponse from ..models import ( Message, @@ -27,7 +28,13 @@ StreamEvent, estimate_tokens, ) -from .messages import _format_messages, _format_tools, _validate_message_content_blocks +from .messages import ( + _format_messages, + _format_responses_messages, + _format_responses_tools, + _format_tools, + _validate_message_content_blocks, +) from .streaming import _StreamingThinkParser logger = logging.getLogger(__name__) @@ -42,17 +49,27 @@ class OpenAIBackend(ModelBackend): + # Class-level default so instances built via `__new__` (unit-test stubs + # that set a bare client mock) still have the flag defined. + responses_enabled: bool = False + def __init__( self, api_key: str, base_url: str, model: str, vision_style: str = "openai", + responses_enabled: Optional[bool] = None, ): self.model = model self.api_key = api_key self.base_url = base_url self.vision_style = vision_style + self.responses_enabled = ( + configs.openai_responses_enabled + if responses_enabled is None + else responses_enabled + ) self.client = AsyncOpenAI( api_key=api_key, base_url=base_url, default_headers=_OPTIONAL_HEADERS ) @@ -85,7 +102,38 @@ async def generate( json.dumps(formatted, indent=2), tools if tools else [], ) - response: OpenAIResponse = await self.client.chat.completions.create( + if self.responses_enabled: + response: OpenAIResponsesResponse = await self.client.responses.create( + model=model if model else self.model, + input=_format_responses_messages( + messages, image_style=self.vision_style + ), + tools=_format_responses_tools(tools) if tools else [], + tool_choice="auto" if tools else "none", + max_output_tokens=max_tokens, + temperature=temperature, + ) + content, tool_calls = self._parse_responses_output(response, tool_calls) + usage = None + if response.usage is not None: + usage = Usage( + prompt_tokens=response.usage.input_tokens or 0, + completion_tokens=response.usage.output_tokens or 0, + ) + else: + usage = Usage( + prompt_tokens=estimate_tokens(json.dumps(formatted)), + completion_tokens=estimate_tokens(content), + estimated=True, + ) + return ModelResponse( + content=content, + tool_calls=tool_calls, + finish_reason="stop", + usage=usage, + ) + + response = await self.client.chat.completions.create( model=model if model else self.model, messages=formatted, max_tokens=max_tokens, @@ -132,7 +180,9 @@ async def generate( return ModelResponse( content=( response.choices[0].message.content - if "response" in locals() and response.choices + if "response" in locals() + and hasattr(response, "choices") + and response.choices else "" ), tool_calls=tool_calls, @@ -145,6 +195,11 @@ async def stream_generate( tools: Optional[List[Dict]] = None, ) -> AsyncIterator[StreamEvent]: try: + if self.responses_enabled: + async for event in self._responses_stream_generate(messages, tools): + yield event + return + formatted_messages = _format_messages( messages, stringify_arguments=True, image_style=self.vision_style ) @@ -281,5 +336,170 @@ async def stream_generate( ) raise + @staticmethod + def _parse_responses_output( + response: OpenAIResponsesResponse, + tool_calls: Optional[List[ToolCall]] = None, + ) -> tuple[str, List[ToolCall]]: + """Extract text content and function-call tool calls from a Responses + API response's ``output`` items.""" + tool_calls = tool_calls if tool_calls is not None else [] + parts: List[str] = [] + for item in response.output: + if getattr(item, "type", "") == "message": + for part in getattr(item, "content", []) or []: + text = getattr(part, "text", None) + if text: + parts.append(text) + elif getattr(item, "type", "") == "function_call": + tool_calls.append( + ToolCall( + id=getattr(item, "call_id", "") or "", + name=getattr(item, "name", ""), + description="", + parameters=json.loads(getattr(item, "arguments", None) or "{}"), + ) + ) + return "".join(parts), tool_calls + + async def _responses_stream_generate( + self, + messages: List[Message], + tools: Optional[List[Dict]] = None, + ) -> AsyncIterator[StreamEvent]: + """Responses API streaming variant of ``stream_generate``. + + Iterates the typed ``ResponseStreamEvent`` SSE stream, feeding text + deltas through the shared think parser (so `` reasoning`` blocks are + still tagged as thinking) and accumulating ``function_call`` items keyed + by ``item_id`` until the end of the stream, when a single + ``ToolCallChunk`` is flushed. Re-raises on failure per the + ``stream_generate`` contract. + """ + input_items = _format_responses_messages( + messages, image_style=self.vision_style + ) + responses_tools = _format_responses_tools(tools) if tools else [] + + try: + response = await self.client.responses.create( + model=self.model, + input=input_items, + tools=responses_tools, + tool_choice="auto" if tools else "none", + stream=True, + stream_options={"include_usage": True}, + ) + except Exception as e: + logger.debug( + "Provider rejected responses stream_options, retrying without usage: %s", + e, + ) + response = await self.client.responses.create( + model=self.model, + input=input_items, + tools=responses_tools, + tool_choice="auto" if tools else "none", + stream=True, + ) + + tool_calls_acc: Dict[str, dict] = {} + parser = _StreamingThinkParser() + streamed_text = "" + usage = None + async for event in response: + etype = getattr(event, "type", "") + if etype == "response.output_text.delta": + delta = getattr(event, "delta", "") or "" + streamed_text += delta + for kind, text in parser.feed(delta): + if text: + yield ( + ThinkingChunk(text) + if kind == "thinking" + else TextChunk(text) + ) + elif etype in ( + "response.reasoning_text.delta", + "response.reasoning_summary_text.delta", + ): + delta = getattr(event, "delta", "") or "" + if delta: + yield ThinkingChunk(delta) + elif etype == "response.function_call_arguments.delta": + item_id = getattr(event, "item_id", "") + entry = tool_calls_acc.setdefault( + item_id, {"name": "", "arguments": ""} + ) + entry["arguments"] += getattr(event, "delta", "") or "" + elif etype in ( + "response.function_call_arguments.done", + "response.output_item.done", + ): + self._fold_responses_tool_call(event, tool_calls_acc) + elif etype == "response.completed": + completed = getattr(event, "response", None) + if ( + completed is not None + and getattr(completed, "usage", None) is not None + ): + r_usage = completed.usage + usage = Usage( + prompt_tokens=getattr(r_usage, "input_tokens", 0) or 0, + completion_tokens=getattr(r_usage, "output_tokens", 0) or 0, + ) + elif etype in ("error", "response.failed"): + raise RuntimeError( + getattr(event, "message", "") or "responses stream failed" + ) + + for kind, text in parser.flush(): + if text: + yield ThinkingChunk(text) if kind == "thinking" else TextChunk(text) + + if tool_calls_acc: + tool_calls = [ + ToolCall( + id=data["call_id"], + name=data["name"], + description="", + parameters=json.loads(data["arguments"] or "{}"), + ) + for data in tool_calls_acc.values() + ] + logger.debug( + "OpenAI responses tool calls: %s", + [(tc.name, tc.parameters) for tc in tool_calls], + ) + yield ToolCallChunk(tool_calls) + + if usage is None: + usage = Usage( + prompt_tokens=estimate_tokens(json.dumps(input_items)), + completion_tokens=estimate_tokens(streamed_text), + estimated=True, + ) + yield UsageChunk(usage) + + @staticmethod + def _fold_responses_tool_call(event, tool_calls_acc: Dict[str, dict]) -> None: + """Fold a completed function-call event/item into the accumulator.""" + item = None + if getattr(event, "type", "") == "response.output_item.done": + item = getattr(event, "item", None) + if item is not None and getattr(item, "type", "") == "function_call": + item_id = getattr(item, "id", "") + entry = tool_calls_acc.setdefault(item_id, {"name": "", "arguments": ""}) + entry["call_id"] = getattr(item, "call_id", "") or item_id + entry["name"] = getattr(item, "name", "") or entry["name"] + entry["arguments"] = getattr(item, "arguments", "") or entry["arguments"] + elif getattr(event, "type", "") == "response.function_call_arguments.done": + item_id = getattr(event, "item_id", "") + entry = tool_calls_acc.setdefault(item_id, {"name": "", "arguments": ""}) + entry["name"] = getattr(event, "name", "") or entry["name"] + entry["arguments"] = getattr(event, "arguments", "") or entry["arguments"] + if "call_id" not in entry: + entry["call_id"] = item_id + __all__ = ["OpenAIBackend"] diff --git a/oli_bot/config.py b/oli_bot/config.py index 92252f4..b7ed4a1 100644 --- a/oli_bot/config.py +++ b/oli_bot/config.py @@ -45,6 +45,9 @@ class AppConfig(BaseSettings): # Optional headers to include in OpenAI API requests. This can be used to # pass additional headers required by certain OpenAI-compatible endpoints. openai_optional_headers: Optional[dict] = Field(default={}) + # Use the OpenAI Responses API (/v1/responses) instead of Chat Completions + # (/v1/chat/completions) for the OpenAI backend. + openai_responses_enabled: bool = Field(default=False) # Ollama ollama_base_url: str = Field(default="http://localhost:11434") diff --git a/oli_bot/settings.py b/oli_bot/settings.py index 15c0031..117e79e 100644 --- a/oli_bot/settings.py +++ b/oli_bot/settings.py @@ -19,6 +19,7 @@ "small_model": "gpt-5-mini", "vision_style": "openai", "optional_headers": {}, + "responses_enabled": False, }, "ollama": { "base_url": "http://localhost:11434", @@ -98,6 +99,7 @@ "OLI_OPENAI_SMALL_MODEL": "openai.small_model", "OLI_OPENAI_VISION_STYLE": "openai.vision_style", "OLI_OPENAI_OPTIONAL_HEADERS": "openai.optional_headers", + "OLI_OPENAI_RESPONSES_ENABLED": "openai.responses_enabled", "OLI_OLLAMA_BASE_URL": "ollama.base_url", "OLI_OLLAMA_MODEL": "ollama.large_model", "OLI_OLLAMA_SMALL_MODEL": "ollama.small_model", @@ -312,6 +314,7 @@ def to_appconfig(self, settings: dict) -> AppConfig: openai_small_model=op.get("small_model", "gpt-5-mini"), openai_vision_style=op.get("vision_style", "openai"), openai_optional_headers=op.get("optional_headers", {}), + openai_responses_enabled=op.get("responses_enabled", False), ollama_base_url=ol.get("base_url", "http://localhost:11434"), ollama_model=ol.get("large_model", ""), ollama_small_model=ol.get("small_model", ""), @@ -370,6 +373,7 @@ def from_appconfig(self, config: AppConfig) -> dict: settings["openai"]["small_model"] = config.openai_small_model settings["openai"]["vision_style"] = config.openai_vision_style settings["openai"]["optional_headers"] = config.openai_optional_headers + settings["openai"]["responses_enabled"] = config.openai_responses_enabled settings["ollama"]["base_url"] = config.ollama_base_url settings["ollama"]["large_model"] = config.ollama_model settings["ollama"]["small_model"] = config.ollama_small_model diff --git a/tests/unit/test_openai_responses_backend.py b/tests/unit/test_openai_responses_backend.py new file mode 100644 index 0000000..b1682b6 --- /dev/null +++ b/tests/unit/test_openai_responses_backend.py @@ -0,0 +1,422 @@ +"""OpenAIBackend Responses API (/v1/responses) coverage. + +Exercises the ``responses_enabled`` path in ``OpenAIBackend``: + * ``generate`` fans out to ``client.responses.create`` and parses message + content + ``function_call`` output items into a ``ModelResponse`` + * usage is taken from ``Response.usage`` when present, else estimated + * ``stream_generate`` converts typed stream events into TextChunk / + ThinkingChunk / ToolCallChunk / UsageChunk and re-raises on failure + * function-call arguments are folded via ``output_item.done`` and + ``function_call_arguments.done`` events + * the ``_format_responses_messages`` / ``_format_responses_tools`` + converters produce Responses-shaped input items, including round-tripping + tool history as ``function_call`` / ``function_call_output`` items +""" + +import json +import pytest +from types import SimpleNamespace + +from oli_bot.backends import OpenAIBackend +from oli_bot.backends.messages import ( + _format_responses_messages, + _format_responses_tools, +) +from oli_bot.models import ( + ImageAttachment, + Message, + TextChunk, + ThinkingChunk, + ToolCallChunk, + UsageChunk, +) + +# --------------------------------------------------------------------------- # +# Fixtures / helpers # +# --------------------------------------------------------------------------- # + + +def _resp_message(*, text="", role="assistant", item_id="msg_1"): + return SimpleNamespace( + type="message", + id=item_id, + role=role, + content=[SimpleNamespace(type="output_text", text=text)] if text else [], + ) + + +def _resp_function_call( + *, call_id="call_1", name="do", arguments='{"a": 1}', item_id="fc_1" +): + return SimpleNamespace( + type="function_call", + id=item_id, + call_id=call_id, + name=name, + arguments=arguments, + ) + + +def _event(etype, **extra): + return SimpleNamespace(type=etype, **extra) + + +def _stream(*events): + class _Iter: + def __aiter__(self_inner): + return self_inner._agen() + + async def _agen(self_inner): + for e in events: + yield e + + return _Iter() + + +class _DummyResponses: + def __init__(self, result): + self._result = result + self.calls = [] + + async def create(self, **kwargs): + self.calls.append(kwargs) + return self._result + + +def _make_backend(*, enabled=True, responses=None, stream=False): + b = OpenAIBackend( + api_key="x", + base_url="https://ex/", + model="gpt-fake", + responses_enabled=enabled, + ) + if stream: + result = _stream(*responses) if responses is not None else _stream() + else: + result = responses + dummy = _DummyResponses(result) + b.client = SimpleNamespace(responses=dummy, chat=SimpleNamespace(completions=None)) + return b, dummy + + +def _msg(**kw): + defaults = {"role": "user", "content": ""} + defaults.update(kw) + return Message(**defaults) + + +# --------------------------------------------------------------------------- # +# Non-streaming generate # +# --------------------------------------------------------------------------- # + + +@pytest.mark.asyncio +async def test_generate_uses_responses_and_parses_output(): + responses = SimpleNamespace( + output=[ + _resp_message(text="hello "), + _resp_message(text="world"), + _resp_function_call(), + ], + usage=SimpleNamespace(input_tokens=11, output_tokens=7, total_tokens=18), + error=None, + ) + b, dummy = _make_backend(responses=responses) + messages = [_msg(role="user", content="hi")] + out = await b.generate(None, messages, tools=[], max_tokens=64) + + assert dummy.calls[0]["model"] == "gpt-fake" + assert dummy.calls[0]["input"] == [{"role": "user", "content": "hi"}] + assert dummy.calls[0]["tool_choice"] == "none" + assert dummy.calls[0]["max_output_tokens"] == 64 + assert dummy.calls[0]["temperature"] == pytest.approx(0.7) + assert out.content == "hello world" + assert len(out.tool_calls) == 1 + assert out.tool_calls[0].id == "call_1" + assert out.tool_calls[0].name == "do" + assert out.tool_calls[0].parameters == {"a": 1} + assert out.usage.prompt_tokens == 11 + assert out.usage.completion_tokens == 7 + assert out.usage.estimated is False + + +@pytest.mark.asyncio +async def test_generate_estimates_usage_when_absent(): + responses = SimpleNamespace( + output=[_resp_message(text="hi")], usage=None, error=None + ) + b, dummy = _make_backend(responses=responses) + out = await b.generate(None, [], tools=[]) + + assert out.content == "hi" + assert out.tool_calls == [] + assert out.usage.estimated is True + assert out.usage.total_tokens > 0 + + +@pytest.mark.asyncio +async def test_generate_responses_error_path(): + b, dummy = _make_backend(responses=None) + + class _Boom: + async def create(self, **_k): + raise RuntimeError("upstream 500") + + b.client = SimpleNamespace( + responses=_Boom(), chat=SimpleNamespace(completions=None) + ) + out = await b.generate(None, [], tools=[]) + assert out.finish_reason == "error" + assert out.content == "" + + +@pytest.mark.asyncio +async def test_disable_flag_still_uses_chat_completions(): + b, dummy = _make_backend(enabled=False) + assert not b.responses_enabled + # The chat-completions path is exercised by the existing suite; here we + # assert the responses client is never consulted when the flag is off. + assert b.client.responses is not None + assert b.client.chat.completions is None + + +# --------------------------------------------------------------------------- # +# Streaming # +# --------------------------------------------------------------------------- # + + +@pytest.mark.asyncio +async def test_stream_yields_text_and_usage(): + events = [ + _event("response.output_text.delta", delta="hello ", item_id="msg_1"), + _event("response.output_text.delta", delta="world", item_id="msg_1"), + _event( + "response.completed", + response=SimpleNamespace( + usage=SimpleNamespace(input_tokens=3, output_tokens=2) + ), + ), + ] + b, dummy = _make_backend(stream=True, responses=events) + out = [e async for e in b.stream_generate([], tools=[])] + + kinds = [type(e).__name__ for e in out] + assert kinds == ["TextChunk", "TextChunk", "UsageChunk"] + assert "".join(e.text for e in out if isinstance(e, TextChunk)) == "hello world" + usage = next(e.usage for e in out if isinstance(e, UsageChunk)) + assert usage.prompt_tokens == 3 + assert usage.completion_tokens == 2 + assert usage.estimated is False + + +@pytest.mark.asyncio +async def test_stream_yields_thinking_from_reasoning_deltas(): + events = [ + _event("response.reasoning_text.delta", delta="let me think", item_id="msg_1"), + _event("response.output_text.delta", delta="answer", item_id="msg_1"), + _event( + "response.completed", + response=SimpleNamespace( + usage=SimpleNamespace(input_tokens=1, output_tokens=1) + ), + ), + ] + b, dummy = _make_backend(stream=True, responses=events) + out = [e async for e in b.stream_generate([], tools=[])] + + thinking = [e for e in out if isinstance(e, ThinkingChunk)] + assert [e.text for e in thinking] == ["let me think"] + + +@pytest.mark.asyncio +async def test_stream_folds_function_call_via_arguments_done(): + events = [ + _event( + "response.function_call_arguments.delta", + item_id="fc_1", + delta='{"a"', + ), + _event( + "response.function_call_arguments.done", + item_id="fc_1", + name="do", + arguments='{"a": 1}', + ), + _event( + "response.completed", + response=SimpleNamespace( + usage=SimpleNamespace(input_tokens=1, output_tokens=1) + ), + ), + ] + b, dummy = _make_backend(stream=True, responses=events) + out = [e async for e in b.stream_generate([], tools=[])] + + tcs = [e for e in out if isinstance(e, ToolCallChunk)] + assert len(tcs) == 1 + call = tcs[0].tool_calls[0] + assert call.name == "do" + assert call.parameters == {"a": 1} + # call_id falls back to item_id when no output_item.done carried it + assert call.id == "fc_1" + + +@pytest.mark.asyncio +async def test_stream_folds_function_call_via_output_item_done(): + events = [ + _event( + "response.function_call_arguments.delta", + item_id="fc_1", + delta='{"b":', + ), + _event( + "response.output_item.done", + item=_resp_function_call( + call_id="call_9", name="run", arguments='{"b": 2}', item_id="fc_1" + ), + ), + _event( + "response.completed", + response=SimpleNamespace( + usage=SimpleNamespace(input_tokens=1, output_tokens=1) + ), + ), + ] + b, dummy = _make_backend(stream=True, responses=events) + out = [e async for e in b.stream_generate([], tools=[])] + + tcs = [e for e in out if isinstance(e, ToolCallChunk)] + assert len(tcs) == 1 + call = tcs[0].tool_calls[0] + assert call.id == "call_9" + assert call.name == "run" + assert call.parameters == {"b": 2} + + +@pytest.mark.asyncio +async def test_stream_estimates_usage_when_completed_missing(): + events = [_event("response.output_text.delta", delta="hi", item_id="msg_1")] + b, dummy = _make_backend(stream=True, responses=events) + out = [e async for e in b.stream_generate([], tools=[])] + + usage = next(e.usage for e in out if isinstance(e, UsageChunk)) + assert usage.estimated is True + + +@pytest.mark.asyncio +async def test_stream_reraises_on_error_event(): + events = [ + _event("error", message="bad auth", code="invalid_api_key"), + ] + b, dummy = _make_backend(stream=True, responses=events) + with pytest.raises(RuntimeError, match="bad auth"): + [e async for e in b.stream_generate([], tools=[])] + + +@pytest.mark.asyncio +async def test_stream_reraises_on_backend_failure(): + class _Boom: + async def create(self, **_k): + raise RuntimeError("upstream 500") + + b = OpenAIBackend( + api_key="x", base_url="https://ex/", model="gpt-fake", responses_enabled=True + ) + b.client = SimpleNamespace( + responses=_Boom(), chat=SimpleNamespace(completions=None) + ) + + with pytest.raises(RuntimeError, match="upstream 500"): + [e async for e in b.stream_generate([], tools=[])] + + +# --------------------------------------------------------------------------- # +# Converters # +# --------------------------------------------------------------------------- # + + +def test_format_responses_messages_round_trips_tool_history(): + messages = [ + _msg(role="system", content="be helpful"), + _msg(role="user", content="calc something"), + Message( + role="assistant", + content="", + tool_calls=[ + { + "id": "call_1", + "type": "function", + "function": { + "name": "calc", + "arguments": json.dumps({"expr": "1+1"}), + }, + } + ], + ), + Message(role="tool", content="2", tool_call_id="call_1"), + ] + items = _format_responses_messages(messages) + assert items == [ + {"role": "system", "content": "be helpful"}, + {"role": "user", "content": "calc something"}, + { + "type": "function_call", + "call_id": "call_1", + "name": "calc", + "arguments": '{"expr": "1+1"}', + }, + {"type": "function_call_output", "call_id": "call_1", "output": "2"}, + ] + + +def test_format_responses_messages_openai_image_parts(): + att = ImageAttachment( + data=b"\x89PNG\r\n\x1a\n", media_type="image/png", width=1, height=1 + ) + messages = [ + Message(role="user", content="what is this", images=[att]), + ] + items = _format_responses_messages(messages, image_style="openai") + assert items[0]["role"] == "user" + parts = items[0]["content"] + assert parts[0] == {"type": "input_text", "text": "what is this"} + assert parts[1]["type"] == "input_image" + assert parts[1]["image_url"].startswith("data:image/png;base64,") + + +def test_format_responses_messages_non_openai_images_drop_bytes(): + att = ImageAttachment(data=b"\x89PNG", media_type="image/png") + messages = [Message(role="user", content="pic", images=[att])] + items = _format_responses_messages(messages, image_style="none") + assert items[0]["role"] == "user" + assert isinstance(items[0]["content"], str) + assert "[Image attached" in items[0]["content"] + + +def test_format_responses_tools_is_flat(): + tools = [ + { + "type": "function", + "function": { + "name": "calc", + "description": "do math", + "parameters": { + "type": "object", + "properties": {"x": {"type": "number"}}, + }, + }, + } + ] + assert _format_responses_tools(tools) == [ + { + "type": "function", + "name": "calc", + "description": "do math", + "parameters": {"type": "object", "properties": {"x": {"type": "number"}}}, + } + ] + + +def test_format_responses_tools_empty(): + assert _format_responses_tools(None) == [] + assert _format_responses_tools([]) == []