Skip to content

Commit 646ce0f

Browse files
Merge pull request #290 from askui/feat/google-messages-api
feat: add support for new openai-compatible endpoint on AskuiAPI in askuiVlmProvider and make sonnet-5 the new default model
2 parents 6091982 + e597076 commit 646ce0f

6 files changed

Lines changed: 368 additions & 32 deletions

File tree

src/askui/model_providers/askui_vlm_provider.py

Lines changed: 122 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,99 @@
1-
"""AskUIVlmProvider — VLM access via AskUI's hosted Anthropic proxy."""
1+
"""AskUIVlmProvider — VLM access via AskUI's hosted model proxies."""
22

33
import os
4+
from enum import Enum
45
from functools import cached_property
56
from typing import Any
67

78
from anthropic import Anthropic
9+
from openai import OpenAI
810
from typing_extensions import override
911

1012
from askui.model_providers.vlm_provider import VlmProvider
1113
from askui.models.anthropic.messages_api import AnthropicMessagesApi
1214
from askui.models.askui.inference_api_settings import AskUiInferenceApiSettings
15+
from askui.models.openai.messages_api import OpenAIMessagesApi
1316
from 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+
)
1826
from askui.models.shared.image_scaler import ImageScaler, PatchOptimizedImageScaler
27+
from askui.models.shared.messages_api import MessagesApi
1928
from askui.models.shared.prompts import SystemPrompt
2029
from 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

2668
class 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,

src/askui/models/anthropic/messages_api.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,16 +58,18 @@ def from_content_block(block: ContentBlockParam) -> BetaContentBlockParam:
5858
"""Convert an internal content block to an Anthropic API-compatible dict.
5959
6060
Uses `model_dump()` to produce plain dicts compatible with Anthropic's
61-
TypedDicts. Strips ``visual_representation`` from `ToolUseBlockParam`
62-
as it is not accepted by the API.
61+
TypedDicts. Strips ``visual_representation`` and ``extra_content`` from
62+
`ToolUseBlockParam` as they are not accepted by the API.
6363
"""
6464
if isinstance(block, ToolUseBlockParam):
65-
# visual_representation is an internal field (perceptual hash for cache
66-
# validation) that does not exist in the Anthropic API schema. Sending
67-
# it would cause the API to reject the request with an unknown-field error.
65+
# visual_representation (perceptual hash for cache validation) and
66+
# extra_content (provider-specific data, e.g. Gemini thought signatures)
67+
# are internal fields that do not exist in the Anthropic API schema.
68+
# Sending them would cause the API to reject the request with an
69+
# unknown-field error.
6870
return cast(
6971
"BetaContentBlockParam",
70-
block.model_dump(exclude={"visual_representation"}),
72+
block.model_dump(exclude={"visual_representation", "extra_content"}),
7173
)
7274
return cast("BetaContentBlockParam", block.model_dump())
7375

src/askui/models/openai/messages_api.py

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -156,16 +156,19 @@ def _convert_assistant_message(
156156
if isinstance(block, TextBlockParam):
157157
text_parts.append(block.text)
158158
elif isinstance(block, ToolUseBlockParam):
159-
tool_calls.append(
160-
{
161-
"id": block.id,
162-
"type": "function",
163-
"function": {
164-
"name": block.name,
165-
"arguments": json.dumps(block.input),
166-
},
167-
}
168-
)
159+
tool_call: dict[str, Any] = {
160+
"id": block.id,
161+
"type": "function",
162+
"function": {
163+
"name": block.name,
164+
"arguments": json.dumps(block.input),
165+
},
166+
}
167+
# Echo back provider-specific data (e.g. Gemini thought signatures)
168+
# so multi-turn tool calling keeps working.
169+
if block.extra_content is not None:
170+
tool_call["extra_content"] = block.extra_content
171+
tool_calls.append(tool_call)
169172
# Skip thinking blocks silently
170173

171174
openai_msg: dict[str, Any] = {"role": "assistant"}
@@ -254,11 +257,18 @@ def _parse_tool_calls(
254257
},
255258
)
256259
arguments = {"raw_arguments": tool_call.function.arguments}
260+
# Gemini (via the OpenAI-compatible API) attaches a `thought_signature`
261+
# inside `extra_content` on each tool call. It must be echoed back on
262+
# subsequent turns or the API rejects the request, so preserve it.
263+
extra_content = (tool_call.model_extra or {}).get("extra_content")
257264
content_blocks.append(
258265
ToolUseBlockParam(
259266
id=tool_call.id,
260267
name=tool_call.function.name,
261268
input=arguments,
269+
extra_content=extra_content
270+
if isinstance(extra_content, dict)
271+
else None,
262272
)
263273
)
264274

src/askui/models/shared/agent_message_param.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,10 @@ class ToolUseBlockParam(BaseModel):
9494
type: Literal["tool_use"] = "tool_use"
9595
cache_control: CacheControlEphemeralParam | None = None
9696
visual_representation: str | None = None # Visual hash for cache validation
97+
# Provider-specific data echoed back on subsequent turns. Used by Gemini via
98+
# the OpenAI-compatible API to carry `thought_signature` (required for tool
99+
# calls to keep working across turns). Not part of the Anthropic API schema.
100+
extra_content: dict[str, Any] | None = None
97101

98102

99103
class BetaThinkingBlock(BaseModel):

0 commit comments

Comments
 (0)