1- """AskUIVlmProvider — VLM access via AskUI's hosted Anthropic proxy ."""
1+ """AskUIVlmProvider — VLM access via AskUI's hosted model proxies ."""
22
33import os
4+ from enum import Enum
45from functools import cached_property
56from typing import Any
67
78from anthropic import Anthropic
9+ from openai import OpenAI
810from typing_extensions import override
911
1012from askui .model_providers .vlm_provider import VlmProvider
1113from askui .models .anthropic .messages_api import AnthropicMessagesApi
1214from askui .models .askui .inference_api_settings import AskUiInferenceApiSettings
15+ from askui .models .openai .messages_api import OpenAIMessagesApi
1316from askui .models .shared .agent_message_param import (
1417 MessageParam ,
1518 ThinkingConfigParam ,
1619 ToolChoiceParam ,
1720)
21+ from askui .models .shared .coordinate_space import (
22+ PixelCoordinateSpace ,
23+ ScaledCoordinateSpace ,
24+ VlmCoordinateSpace ,
25+ )
1826from askui .models .shared .image_scaler import ImageScaler , PatchOptimizedImageScaler
27+ from askui .models .shared .messages_api import MessagesApi
1928from askui .models .shared .prompts import SystemPrompt
2029from askui .models .shared .tools import ToolCollection
2130
22- _DEFAULT_MODEL_ID = "claude-sonnet-4-6 "
31+ _DEFAULT_MODEL_ID = "claude-sonnet-5 "
2332_DEFAULT_MAX_IMAGE_EDGE = 1024
33+ # Claude emits native pixel coordinates; Gemini emits coordinates in a
34+ # 1000x1000 normalised grid.
35+ _ANTHROPIC_COORDINATE_SPACE = PixelCoordinateSpace ()
36+ _GOOGLE_COORDINATE_SPACE = ScaledCoordinateSpace (width = 1000 , height = 1000 )
37+
38+
39+ class _Backend (Enum ):
40+ """The AskUI proxy backend a model is served through."""
41+
42+ ANTHROPIC = "anthropic"
43+ GOOGLE = "google"
44+ OPENAI = "openai"
45+
46+
47+ def _infer_backend (model_id : str ) -> _Backend :
48+ """Infer the AskUI proxy backend that serves ``model_id``.
49+
50+ Claude models route to the Anthropic-compatible proxy; Gemini models (with
51+ or without a ``google/`` vendor prefix) route to the OpenAI-compatible proxy.
52+
53+ Raises:
54+ ValueError: If no backend can be inferred from ``model_id``.
55+ """
56+ normalized = model_id .lower ()
57+ if "claude" in normalized :
58+ return _Backend .ANTHROPIC
59+ if "gemini" in normalized :
60+ return _Backend .GOOGLE
61+ error_msg = (
62+ f"Cannot infer a backend for model id { model_id !r} . Expected the model "
63+ f"id to reference a Claude or Gemini model."
64+ )
65+ raise ValueError (error_msg )
2466
2567
2668class AskUIVlmProvider (VlmProvider ):
27- """VLM provider that routes requests through AskUI's hosted Anthropic proxy.
69+ """VLM provider that routes requests through AskUI's hosted model proxies.
70+
71+ The proxy used is selected from `model_id`:
72+
73+ - Anthropic (Claude) models are served via the Anthropic-compatible proxy
74+ (``/proxy/anthropic``) using the `AnthropicMessagesApi`.
75+ - OpenAI-compatible models (e.g. Gemini) are served via the OpenAI-compatible
76+ proxy (``/proxy/openai/v1/chat/completions``) using the `OpenAIMessagesApi`.
2877
29- Supports Claude 4.x generation models. Credentials are read from environment
30- variables (`ASKUI_WORKSPACE_ID`, `ASKUI_TOKEN`) lazily — validation happens
31- on the first API call, not at construction time.
78+ The backend is inferred from the model-id prefix (see `_infer_backend`); a
79+ `ValueError` is raised when it cannot be determined.
80+
81+ Credentials are read from environment variables (`ASKUI_WORKSPACE_ID`,
82+ `ASKUI_TOKEN`) lazily — validation happens on the first API call, not at
83+ construction time.
3284
3385 Args:
3486 askui_settings (`AskUiInferenceApiSettings` | None, optional):
3587 Connection settings (workspace ID, token, base URL). Reads
3688 from environment variables if not provided.
37- model_id (str | None, optional): Claude model to use. Defaults to
38- ``"claude-sonnet-4-6"``.
39- client (`Anthropic` | None, optional): Pre-configured Anthropic client.
40- If provided, ``askui_settings`` is only used for the base URL.
89+ model_id (str | None, optional): Model to use. Defaults to
90+ ``"claude-sonnet-4-6"``. Pass a Gemini model id (e.g.
91+ ``"gemini-3.5-pro"``) to route through the OpenAI-compatible proxy.
92+ client (`Anthropic` | `OpenAI` | None, optional): Pre-configured client.
93+ Pass an `Anthropic` client for Claude models or an `OpenAI` client
94+ for Gemini models. It is used only when it matches the proxy the
95+ configured ``model_id`` routes to; otherwise a client is built from
96+ ``askui_settings``.
4197 image_scaler (`ImageScaler` | None, optional): Custom image preprocessing
4298 callable. If ``None``, uses Anthropic-optimized patch-based scaling
4399 controlled by ``image_edge_max``.
@@ -63,15 +119,15 @@ def __init__(
63119 self ,
64120 askui_settings : AskUiInferenceApiSettings | None = None ,
65121 model_id : str | None = None ,
66- client : Anthropic | None = None ,
122+ client : Anthropic | OpenAI | None = None ,
67123 image_scaler : ImageScaler | None = None ,
68124 image_edge_max : int | None = None ,
69125 ) -> None :
70126 self ._askui_settings = askui_settings or AskUiInferenceApiSettings ()
71127 self ._model_id_value = (
72128 model_id or os .environ .get ("VLM_PROVIDER_MODEL_ID" ) or _DEFAULT_MODEL_ID
73129 )
74- self ._injected_client = client
130+ self ._client = client
75131 resolved_edge_max = (
76132 image_edge_max
77133 or int (os .environ .get ("ASKUI_VLM_MAX_IMAGE_EDGE" , "0" ))
@@ -91,11 +147,32 @@ def model_id(self) -> str:
91147 def image_scaler (self ) -> ImageScaler :
92148 return self ._image_scaler
93149
150+ @property
151+ @override
152+ def coordinate_space (self ) -> VlmCoordinateSpace :
153+ """The coordinate grid the configured model emits coordinates in.
154+
155+ Gemini (OpenAI proxy) emits coordinates in a 1000x1000 normalised grid;
156+ Claude emits native pixel coordinates.
157+ """
158+ if self ._backend is _Backend .GOOGLE :
159+ return _GOOGLE_COORDINATE_SPACE
160+ return _ANTHROPIC_COORDINATE_SPACE
161+
162+ @cached_property
163+ def _backend (self ) -> _Backend :
164+ return _infer_backend (self ._model_id_value )
165+
94166 @cached_property
95- def _messages_api (self ) -> AnthropicMessagesApi :
96- """Lazily initialise the AnthropicMessagesApi on first use."""
97- if self ._injected_client is not None :
98- return AnthropicMessagesApi (client = self ._injected_client )
167+ def _messages_api (self ) -> MessagesApi :
168+ """Lazily initialise the `MessagesApi` matching the configured model."""
169+ if self ._backend is _Backend .OPENAI or self ._backend is _Backend .GOOGLE :
170+ return self ._build_openai_messages_api ()
171+ return self ._build_anthropic_messages_api ()
172+
173+ def _build_anthropic_messages_api (self ) -> AnthropicMessagesApi :
174+ if isinstance (self ._client , Anthropic ):
175+ return AnthropicMessagesApi (client = self ._client )
99176
100177 # TODO askui_settings.verify_ssl are not considered! #noqa
101178 # if self._askui_settings.verify_ssl:
@@ -110,6 +187,33 @@ def _messages_api(self) -> AnthropicMessagesApi:
110187 )
111188 return AnthropicMessagesApi (client = client )
112189
190+ def _build_openai_messages_api (self ) -> OpenAIMessagesApi :
191+ if isinstance (self ._client , OpenAI ):
192+ return OpenAIMessagesApi (client = self ._client )
193+
194+ client = OpenAI (
195+ api_key = "DummyValueRequiredByOpenAIClient" ,
196+ base_url = f"{ self ._askui_settings .base_url } /proxy/openai/v1" ,
197+ default_headers = {
198+ "Authorization" : self ._askui_settings .authorization_header
199+ },
200+ )
201+ return OpenAIMessagesApi (client = client )
202+
203+ @override
204+ def augment_system_prompt (self , system : SystemPrompt ) -> SystemPrompt :
205+ """Append coordinate info to the system prompt for OpenAI-proxy models.
206+
207+ Claude emits pixel coordinates natively, so the prompt is returned
208+ unchanged. Models routed through the OpenAI proxy (e.g. Gemini) are told
209+ which coordinate grid to emit so their output can be mapped back via
210+ `coordinate_space`.
211+ """
212+ if self ._backend is not _Backend .GOOGLE :
213+ return system
214+ coord_info = self .coordinate_space .build_prompt_section ()
215+ return SystemPrompt (prompt = f"{ str (system )} \n \n { coord_info } " )
216+
113217 @override
114218 def create_message (
115219 self ,
@@ -122,6 +226,8 @@ def create_message(
122226 temperature : float | None = None ,
123227 provider_options : dict [str , Any ] | None = None ,
124228 ) -> MessageParam :
229+ if system is not None :
230+ system = self .augment_system_prompt (system )
125231 result : MessageParam = self ._messages_api .create_message (
126232 messages = messages ,
127233 model_id = self ._model_id_value ,
0 commit comments