Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,7 @@ codex -p oss
### Browser

> [!WARNING]
> This implementation is purely for educational purposes and should not be used in production. You should implement your own equivalent of the [`ExaBackend`](gpt_oss/tools/simple_browser/backend.py) class with your own browsing environment.
> This implementation is purely for educational purposes and should not be used in production. You should implement your own equivalent of the [`YouComBackend`](gpt_oss/tools/simple_browser/backend.py) class with your own browsing environment. Currently we have available `YouComBackend` and `ExaBackend`.

Both gpt-oss models were trained with the capability to browse using the `browser` tool that exposes the following three methods:

Expand All @@ -441,15 +441,20 @@ To enable the browser tool, you'll have to place the definition into the `system
```python
import datetime
from gpt_oss.tools.simple_browser import SimpleBrowserTool
from gpt_oss.tools.simple_browser.backend import ExaBackend
from gpt_oss.tools.simple_browser.backend import YouComBackend
from openai_harmony import SystemContent, Message, Conversation, Role, load_harmony_encoding, HarmonyEncodingName

encoding = load_harmony_encoding(HarmonyEncodingName.HARMONY_GPT_OSS)

# Exa backend requires you to have set the EXA_API_KEY environment variable
backend = ExaBackend(
# Depending on the choice of the browser backend you need corresponding env variables setup
# In case you use You.com backend requires you to have set the YDC_API_KEY environment variable,
# while for Exa you might need EXA_API_KEY environment variable set
backend = YouComBackend(
source="web",
)
# backend = ExaBackend(
# source="web",
# )
browser_tool = SimpleBrowserTool(backend=backend)

# create a basic system prompt
Expand Down
12 changes: 9 additions & 3 deletions gpt-oss-mcp-server/browser_server.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from typing import Union, Optional

from mcp.server.fastmcp import Context, FastMCP
from gpt_oss.tools.simple_browser import SimpleBrowserTool
from gpt_oss.tools.simple_browser.backend import ExaBackend

from gpt_oss.tools.simple_browser.backend import YouComBackend, ExaBackend

@dataclass
class AppContext:
browsers: dict[str, SimpleBrowserTool] = field(default_factory=dict)

def create_or_get_browser(self, session_id: str) -> SimpleBrowserTool:
if session_id not in self.browsers:
backend = ExaBackend(source="web")
tool_backend = os.getenv("BROWSER_BACKEND", "exa")
if tool_backend == "youcom":
backend = YouComBackend(source="web")
elif tool_backend == "exa":
backend = ExaBackend(source="web")
else:
raise ValueError(f"Invalid tool backend: {tool_backend}")
self.browsers[session_id] = SimpleBrowserTool(backend=backend)
return self.browsers[session_id]

Expand Down
4 changes: 2 additions & 2 deletions gpt-oss-mcp-server/reference-system-prompt.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import datetime

from gpt_oss.tools.simple_browser import SimpleBrowserTool
from gpt_oss.tools.simple_browser.backend import ExaBackend
from gpt_oss.tools.simple_browser.backend import YouComBackend
from gpt_oss.tools.python_docker.docker_tool import PythonTool
from gpt_oss.tokenizer import tokenizer

Expand All @@ -22,7 +22,7 @@
ReasoningEffort.LOW).with_conversation_start_date(
datetime.datetime.now().strftime("%Y-%m-%d")))

backend = ExaBackend(source="web", )
backend = YouComBackend(source="web")
browser_tool = SimpleBrowserTool(backend=backend)
system_message_content = system_message_content.with_tools(
browser_tool.tool_config)
Expand Down
4 changes: 2 additions & 2 deletions gpt_oss/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

from gpt_oss.tools import apply_patch
from gpt_oss.tools.simple_browser import SimpleBrowserTool
from gpt_oss.tools.simple_browser.backend import ExaBackend
from gpt_oss.tools.simple_browser.backend import YouComBackend
from gpt_oss.tools.python_docker.docker_tool import PythonTool

from openai_harmony import (
Expand Down Expand Up @@ -85,7 +85,7 @@ def main(args):
)

if args.browser:
backend = ExaBackend(
backend = YouComBackend(
source="web",
)
browser_tool = SimpleBrowserTool(backend=backend)
Expand Down
13 changes: 9 additions & 4 deletions gpt_oss/responses_api/api_server.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
import datetime
import uuid
from typing import Callable, Literal, Optional
Expand All @@ -20,7 +21,7 @@

from gpt_oss.tools.python_docker.docker_tool import PythonTool
from gpt_oss.tools.simple_browser import SimpleBrowserTool
from gpt_oss.tools.simple_browser.backend import ExaBackend
from gpt_oss.tools.simple_browser.backend import YouComBackend, ExaBackend

from .events import (
ResponseCodeInterpreterCallCompleted,
Expand Down Expand Up @@ -904,9 +905,13 @@ async def generate(body: ResponsesRequest, request: Request):
)

if use_browser_tool:
backend = ExaBackend(
source="web",
)
tool_backend = os.getenv("BROWSER_BACKEND", "exa")
if tool_backend == "youcom":
backend = YouComBackend(source="web")
elif tool_backend == "exa":
backend = ExaBackend(source="web")
else:
raise ValueError(f"Invalid tool backend: {tool_backend}")
browser_tool = SimpleBrowserTool(backend=backend)
else:
browser_tool = None
Expand Down
3 changes: 2 additions & 1 deletion gpt_oss/tools/simple_browser/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from .simple_browser_tool import SimpleBrowserTool
from .backend import ExaBackend
from .backend import ExaBackend, YouComBackend

__all__ = [
"SimpleBrowserTool",
"ExaBackend",
"YouComBackend",
]
102 changes: 94 additions & 8 deletions gpt_oss/tools/simple_browser/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""

import functools
import asyncio
import logging
import os
from abc import abstractmethod
Expand Down Expand Up @@ -87,6 +88,24 @@ async def search(
async def fetch(self, url: str, session: ClientSession) -> PageContents:
pass

async def _post(self, session: ClientSession, endpoint: str, payload: dict) -> dict:
headers = {"x-api-key": self._get_api_key()}
async with session.post(f"{self.BASE_URL}{endpoint}", json=payload, headers=headers) as resp:
if resp.status != 200:
raise BackendError(
f"{self.__class__.__name__} error {resp.status}: {await resp.text()}"
)
return await resp.json()

async def _get(self, session: ClientSession, endpoint: str, params: dict) -> dict:
headers = {"x-api-key": self._get_api_key()}
async with session.get(f"{self.BASE_URL}{endpoint}", params=params, headers=headers) as resp:
if resp.status != 200:
raise BackendError(
f"{self.__class__.__name__} error {resp.status}: {await resp.text()}"
)
return await resp.json()


@chz.chz(typecheck=True)
class ExaBackend(Backend):
Expand All @@ -106,14 +125,6 @@ def _get_api_key(self) -> str:
raise BackendError("Exa API key not provided")
return key

async def _post(self, session: ClientSession, endpoint: str, payload: dict) -> dict:
headers = {"x-api-key": self._get_api_key()}
async with session.post(f"{self.BASE_URL}{endpoint}", json=payload, headers=headers) as resp:
if resp.status != 200:
raise BackendError(
f"Exa API error {resp.status}: {await resp.text()}"
)
return await resp.json()

async def search(
self, query: str, topn: int, session: ClientSession
Expand Down Expand Up @@ -164,3 +175,78 @@ async def fetch(self, url: str, session: ClientSession) -> PageContents:
display_urls=True,
session=session,
)

@chz.chz(typecheck=True)
class YouComBackend(Backend):
"""Backend that uses the You.com Search API."""

source: str = chz.field(doc="Description of the backend source")

BASE_URL: str = "https://api.ydc-index.io"

def _get_api_key(self) -> str:
key = os.environ.get("YDC_API_KEY")
if not key:
raise BackendError("You.com API key not provided")
return key


async def search(
self, query: str, topn: int, session: ClientSession
) -> PageContents:
data = await self._get(
session,
"/v1/search",
{"query": query, "count": topn},
)
# make a simple HTML page to work with browser format
web_titles_and_urls, news_titles_and_urls = [], []
if "web" in data["results"]:
web_titles_and_urls = [
(result["title"], result["url"], result["snippets"])
for result in data["results"]["web"]
]
if "news" in data["results"]:
news_titles_and_urls = [
(result["title"], result["url"], result["description"])
for result in data["results"]["news"]
]
titles_and_urls = web_titles_and_urls + news_titles_and_urls
html_page = f"""
<html><body>
<h1>Search Results</h1>
<ul>
{"".join([f"<li><a href='{url}'>{title}</a> {summary}</li>" for title, url, summary in titles_and_urls])}
</ul>
</body></html>
"""

return process_html(
html=html_page,
url="",
title=query,
display_urls=True,
session=session,
)

async def fetch(self, url: str, session: ClientSession) -> PageContents:
is_view_source = url.startswith(VIEW_SOURCE_PREFIX)
if is_view_source:
url = url[len(VIEW_SOURCE_PREFIX) :]
data = await self._post(
session,
"/v1/contents",
{"urls": [url], "livecrawl_formats": "html"},
)
if not data:
raise BackendError(f"No contents returned for {url}")
if "html" not in data[0]:
raise BackendError(f"No HTML returned for {url}")
return process_html(
html=data[0].get("html", ""),
url=url,
title=data[0].get("title", ""),
display_urls=True,
session=session,
)

70 changes: 70 additions & 0 deletions tests/gpt_oss/tools/simple_browser/test_backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import pytest
from typing import Generator, Any
from unittest import mock
from aiohttp import ClientSession

from gpt_oss.tools.simple_browser.backend import YouComBackend

class MockAiohttpResponse:
"""Mocks responses for get/post requests from async libraries."""

def __init__(self, json: dict, status: int):
self._json = json
self.status = status

async def json(self):
return self._json

async def __aexit__(self, exc_type, exc, tb):
pass

async def __aenter__(self):
return self

def mock_os_environ_get(name: str, default: Any = "test_api_key"):
assert name in ["YDC_API_KEY"]
return default

def test_youcom_backend():
backend = YouComBackend(source="web")
assert backend.source == "web"

@pytest.mark.asyncio
@mock.patch("aiohttp.ClientSession.get")
async def test_youcom_backend_search(mock_session_get):
backend = YouComBackend(source="web")
api_response = {
"results": {
"web": [
{"title": "Web Result 1", "url": "https://www.example.com/web1", "snippets": "Web Result 1 snippets"},
{"title": "Web Result 2", "url": "https://www.example.com/web2", "snippets": "Web Result 2 snippets"},
],
"news": [
{"title": "News Result 1", "url": "https://www.example.com/news1", "description": "News Result 1 description"},
{"title": "News Result 2", "url": "https://www.example.com/news2", "description": "News Result 2 description"},
],
}
}
with mock.patch("os.environ.get", wraps=mock_os_environ_get):
mock_session_get.return_value = MockAiohttpResponse(api_response, 200)
async with ClientSession() as session:
result = await backend.search(query="test", topn=10, session=session)
assert result.title == "test"
assert result.urls == {"0": "https://www.example.com/web1", "1": "https://www.example.com/web2", "2": "https://www.example.com/news1", "3": "https://www.example.com/news2"}

@pytest.mark.asyncio
@mock.patch("aiohttp.ClientSession.post")
async def test_youcom_backend_fetch(mock_session_get):
backend = YouComBackend(source="web")
api_response = [
{"title": "Fetch Result 1", "url": "https://www.example.com/fetch1", "html": "<div>Fetch Result 1 text</div>"},
]
with mock.patch("os.environ.get", wraps=mock_os_environ_get):
mock_session_get.return_value = MockAiohttpResponse(api_response, 200)
async with ClientSession() as session:
result = await backend.fetch(url="https://www.example.com/fetch1", session=session)
assert result.title == "Fetch Result 1"
assert result.text == "\nURL: https://www.example.com/fetch1\nFetch Result 1 text"