External Providers¶
External providers are added by implementing the Model protocol and registering the implementation.
You can register a provider in application code:
Or from an installed package with the uisurf_agent.models entry-point group:
Custom providers that support tool calling should also read browser, desktop,
and mobile tool declarations from tool_registry. See
Tool Extensions for the registry API.
Minimal Model¶
This minimal model returns a final answer and never calls tools.
from typing import Any
from uisurf_agent import ModelTurn, Observation, ToolResult, register_provider
@register_provider("echo")
class EchoModel:
async def encode_observation(self, observation: Observation) -> dict[str, Any]:
return {
"task": observation.task,
"screenshot": observation.screenshot,
"mime_type": observation.mime_type,
"metadata": observation.metadata,
}
async def generate_response(self, history: list[Any]) -> ModelTurn:
return ModelTurn(text="I received the UI observation.")
async def add_response_to_history(
self,
response: ModelTurn,
history: list[Any],
) -> None:
history.append({"role": "assistant", "text": response.text})
async def encode_action_results(
self,
results: list[ToolResult],
) -> dict[str, Any]:
return {"role": "tool", "results": results}
def prepare_history(
self,
history: list[Any],
max_observation_images: int,
) -> list[Any]:
return history
Use it:
OpenAI-Compatible Example¶
This example targets OpenAI-compatible /v1/chat/completions servers such as vLLM, LM Studio, or local gateways.
import base64
import json
from typing import Any
from openai import AsyncOpenAI
from uisurf_agent import ModelTurn, Observation, ToolCall, ToolResult, register_provider
@register_provider("openai-compatible")
class OpenAICompatibleModel:
def __init__(
self,
model_id: str,
base_url: str,
api_key: str = "not-needed",
**_: Any,
) -> None:
self.model_id = model_id
self.client = AsyncOpenAI(base_url=base_url, api_key=api_key)
async def encode_observation(self, observation: Observation) -> dict[str, Any]:
image = base64.b64encode(observation.screenshot).decode("utf-8")
content = []
if observation.task:
content.append({"type": "text", "text": observation.task})
content.append({
"type": "image_url",
"image_url": {
"url": f"data:{observation.mime_type};base64,{image}",
},
})
return {"role": "user", "content": content}
async def generate_response(self, history: list[Any]) -> ModelTurn:
response = await self.client.chat.completions.create(
model=self.model_id,
messages=[
{
"role": "system",
"content": (
"You control a UI through tools. Return JSON only: "
'{"text": string|null, "tool_calls": [{"name": string, "args": object}]}'
),
},
*history,
],
temperature=0,
)
raw_text = response.choices[0].message.content or "{}"
try:
payload = json.loads(raw_text)
except json.JSONDecodeError:
return ModelTurn(text=raw_text, raw=response)
tool_calls = [
ToolCall(
id=None,
name=item["name"],
args=item.get("args", {}),
)
for item in payload.get("tool_calls", [])
]
return ModelTurn(
text=payload.get("text"),
tool_calls=tool_calls,
raw=response,
)
async def add_response_to_history(
self,
response: ModelTurn,
history: list[Any],
) -> None:
if response.tool_calls:
history.append({
"role": "assistant",
"content": json.dumps({
"text": response.text,
"tool_calls": [
{"name": call.name, "args": call.args}
for call in response.tool_calls
],
}),
})
return
if response.text:
history.append({"role": "assistant", "content": response.text})
async def encode_action_results(
self,
results: list[ToolResult],
) -> dict[str, Any]:
content = []
for result in results:
content.append({
"type": "text",
"text": json.dumps({
"tool": result.name,
"result": result.result,
"metadata": result.metadata,
"safety_acknowledged": result.safety_acknowledged,
}),
})
if result.screenshot:
image = base64.b64encode(result.screenshot).decode("utf-8")
content.append({
"type": "image_url",
"image_url": {
"url": f"data:{result.mime_type};base64,{image}",
},
})
return {"role": "user", "content": content}
def prepare_history(
self,
history: list[Any],
max_observation_images: int,
) -> list[Any]:
image_indexes = [
index
for index, message in enumerate(history)
if isinstance(message.get("content"), list)
and any(
isinstance(part, dict) and part.get("type") == "image_url"
for part in message["content"]
)
]
keep = set(image_indexes[-max_observation_images:])
compacted = []
for index, message in enumerate(history):
if index in keep or index not in image_indexes:
compacted.append(message)
continue
content = [
part
for part in message.get("content", [])
if not (isinstance(part, dict) and part.get("type") == "image_url")
]
if content:
compacted.append({**message, "content": content})
return compacted
Use it from Python:
from uisurf_agent import BrowserAgent
agent = BrowserAgent(
provider_name="openai-compatible",
model_id="Qwen/Qwen2.5-VL-7B-Instruct",
model_base_url="http://localhost:8000/v1",
)
Use it from the CLI after the package/module containing the registration has been imported or exposed through entry points:
uv run uisurf_agent run browser_agent \
--provider openai-compatible \
--model-id Qwen/Qwen2.5-VL-7B-Instruct \
--model-base-url http://localhost:8000/v1 \
--task "Open example.com and summarize the page"
Included Browser Model Examples¶
The repository includes two example provider adapters for local visual browser
models. Both examples use BrowserAgent observations as input: the agent
captures a screenshot, passes the current task and URL to the provider adapter,
parses the model response into a browser action, then executes that action
through the agent runtime.
Fara-7B¶
Fara-7B is a Microsoft Research computer-use model with 7 billion parameters. Its model card describes it as a multimodal, decoder-only model that takes screenshots plus text context and predicts grounded thoughts and actions for browser automation. The model is designed for web tasks where the model repeatedly observes the page, reasons about the next step, and emits action arguments such as click coordinates or URLs.
Fara-7B can be served through runtimes such as vLLM or SGLang that expose an
OpenAI-compatible /v1/chat/completions API. The model card shows this pattern
with vllm serve "microsoft/Fara-7B" followed by a request to
/v1/chat/completions. In other words, the serving runtime provides the API
compatibility; the Fara example consumes that API.
Use examples/fara_example.py when you have a local OpenAI-compatible Fara
server running. The example registers a fara provider and points
BrowserAgent at that local /v1/chat/completions service:
async with BrowserAgent(
provider_name="fara",
model_id="microsoft/Fara-7B",
model_base_url="http://localhost:5000/v1",
auto_mode=True,
max_observation_images=5,
) as agent:
async for event in agent.run(
"Go to wikipedia and search for Large Language Models.",
max_steps=20,
):
print(event)
Run it from the repository root:
MolmoWeb¶
MolmoWeb is an Ai2 open visual web agent
built on the Molmo 2 multimodal model family. Ai2 describes it as a self-hosted
browser agent that observes live webpages through screenshots and predicts the
next step, such as clicking, typing, scrolling, or navigating. In this UISurf
example, the local /predict endpoint is used only for model inference; it
returns text for the adapter to parse, and BrowserAgent performs the actual
browser action through Playwright.
The public release emphasizes open weights, training data, code, and evaluation
tools for building and inspecting web agents.
Use examples/molmoweb_example.py when you have a local MolmoWeb /predict
server running. The example registers a molmoweb provider, sends each browser
screenshot as image_base64 with a task prompt, and maps the model's response
into UISurf ToolCall objects for the agent to execute:
async with BrowserAgent(
provider_name="molmoweb",
model_base_url="http://127.0.0.1:8001/predict",
auto_mode=True,
max_observation_images=1,
) as agent:
async for event in agent.run(
"Go to https://www.ibm.com/think/topics/large-language-models, read the page and tell me whats is the title of the article",
max_steps=20,
):
print(event)
Run it from the repository root:
Implementation Checklist¶
Every custom model must decide how to:
- encode an
Observationinto the provider's user message format - call the provider and normalize the response into
ModelTurn - add the provider's assistant message back into history
- encode
ToolResultobjects into the provider's tool-result format - prepare old history for the next model call through
prepare_history
For human confirmation, emit a ToolCall with args["safety_decision"]. The agent runtime handles prompting and approval.