Skip to content

Commit 7074c29

Browse files
committed
feat(models)!: based get model on google genai api
- `askui` model now uses `gemini-2.5-flash` as default model falling back to original `askui` model (Inference API's VQA endpoint) if the Google GenAI API fails, e.g., because of missing support of schema or for unknown reason. For example, Google GenAI API does not support recursive schemas at the moment. - `askui/gemini-2.5-flash` and `askui/gemini-2.5-pro` are now supported as model choices. - We are using an AskUI hosted VertexAI proxy for the Google GenAI API to ensure compliance, e.g., only EU hosting. BREAKING CHANGE: - The `askui`/default model for `AgentBase.get()` changed.
1 parent efd12ef commit 7074c29

10 files changed

Lines changed: 302 additions & 16 deletions

File tree

README.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -731,10 +731,8 @@ with VisionAgent() as agent:
731731
```
732732

733733
**⚠️ Limitations:**
734-
- Not all models support response schemas or all kinds of properties that a response schema can have at the moment
735-
- Default values are not supported, e.g., `url: str = "github.com"` or `url: str | None = None`. This includes `default_factory`
736-
and `default` args of `pydantic.Field` as well, e.g., `url: str = Field(default="github.com")` or
737-
`url: str = Field(default_factory=lambda: "github.com")`.
734+
- The support for response schemas varies among models. Currently, the `askui` model provides best support for response schemas
735+
as we try different models under the hood with your schema to see which one works best.
738736

739737
## What is AskUI Vision Agent?
740738

pdm.lock

Lines changed: 88 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ dependencies = [
2222
"segment-analytics-python>=2.3.4",
2323
"tenacity>=9.1.2",
2424
"jsonref>=1.1.0",
25+
"google-genai>=1.20.0",
2526
]
2627
requires-python = ">=3.10"
2728
readme = "README.md"
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
from typing import Type
2+
3+
from google.genai.errors import ClientError
4+
from typing_extensions import override
5+
6+
from askui.logger import logger
7+
from askui.models.askui.google_genai_api import AskUiGoogleGenAiApi
8+
from askui.models.askui.inference_api import AskUiInferenceApi
9+
from askui.models.exceptions import QueryNoResponseError, QueryUnexpectedResponseError
10+
from askui.models.models import GetModel
11+
from askui.models.types.response_schemas import ResponseSchema
12+
from askui.utils.image_utils import ImageSource
13+
14+
15+
class AskUiGetModel(GetModel):
16+
"""A GetModel implementation that is supposed to be as comprehensive and
17+
powerful as possible using the available AskUi models.
18+
19+
This model first attempts to use the Google GenAI API for information extraction.
20+
If the Google GenAI API fails (e.g., no response, unexpected response, or other
21+
errors), it falls back to using the AskUI Inference API.
22+
23+
Args:
24+
google_genai_api (AskUiGoogleGenAiApi): The Google GenAI API instance to use
25+
as primary.
26+
inference_api (AskUiInferenceApi): The Inference API instance to use as
27+
fallback.
28+
"""
29+
30+
def __init__(
31+
self,
32+
google_genai_api: AskUiGoogleGenAiApi,
33+
inference_api: AskUiInferenceApi,
34+
) -> None:
35+
self._google_genai_api = google_genai_api
36+
self._inference_api = inference_api
37+
38+
@override
39+
def get(
40+
self,
41+
query: str,
42+
image: ImageSource,
43+
response_schema: Type[ResponseSchema] | None,
44+
model_choice: str,
45+
) -> ResponseSchema | str:
46+
try:
47+
logger.debug("Attempting to use Google GenAI API")
48+
return self._google_genai_api.get(
49+
query=query,
50+
image=image,
51+
response_schema=response_schema,
52+
model_choice=model_choice,
53+
)
54+
except (
55+
ClientError,
56+
QueryNoResponseError,
57+
QueryUnexpectedResponseError,
58+
NotImplementedError,
59+
) as e:
60+
if isinstance(e, ClientError) and e.code != 400:
61+
raise
62+
logger.debug(
63+
f"Google GenAI API failed with error that may not occur with other "
64+
f"models/apis: {e}"
65+
". Falling back to Inference API..."
66+
)
67+
return self._inference_api.get(
68+
query=query,
69+
image=image,
70+
response_schema=response_schema,
71+
model_choice=model_choice,
72+
)
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import json as json_lib
2+
from typing import Type
3+
4+
import google.genai as genai
5+
from google.genai import types as genai_types
6+
from pydantic import ValidationError
7+
from typing_extensions import override
8+
9+
from askui.logger import logger
10+
from askui.models.askui.inference_api import AskUiInferenceApiSettings
11+
from askui.models.exceptions import QueryNoResponseError, QueryUnexpectedResponseError
12+
from askui.models.models import GetModel, ModelName
13+
from askui.models.shared.prompts import SYSTEM_PROMPT_GET
14+
from askui.models.types.response_schemas import ResponseSchema, to_response_schema
15+
from askui.utils.image_utils import ImageSource
16+
17+
ASKUI_MODEL_CHOICE_PREFIX = "askui/"
18+
ASKUI_MODEL_CHOICE_PREFIX_LEN = len(ASKUI_MODEL_CHOICE_PREFIX)
19+
20+
21+
def _extract_model_id(model_choice: str) -> str:
22+
if model_choice == ModelName.ASKUI:
23+
return ModelName.GEMINI__2_5__FLASH
24+
if model_choice.startswith(ASKUI_MODEL_CHOICE_PREFIX):
25+
return model_choice[ASKUI_MODEL_CHOICE_PREFIX_LEN:]
26+
return model_choice
27+
28+
29+
class AskUiGoogleGenAiApi(GetModel):
30+
def __init__(self, settings: AskUiInferenceApiSettings | None = None) -> None:
31+
self._settings = settings or AskUiInferenceApiSettings()
32+
self._client = genai.Client(
33+
vertexai=True,
34+
api_key="Necessary",
35+
http_options=genai_types.HttpOptions(
36+
base_url=f"{self._settings.base_url}/proxy/vertexai",
37+
headers={
38+
"Authorization": self._settings.authorization_header,
39+
},
40+
),
41+
)
42+
43+
@override
44+
def get(
45+
self,
46+
query: str,
47+
image: ImageSource,
48+
response_schema: Type[ResponseSchema] | None,
49+
model_choice: str,
50+
) -> ResponseSchema | str:
51+
try:
52+
_response_schema = to_response_schema(response_schema)
53+
json_schema = _response_schema.model_json_schema()
54+
logger.debug(f"json_schema:\n{json_lib.dumps(json_schema)}")
55+
content = genai_types.Content(
56+
parts=[
57+
genai_types.Part.from_bytes(
58+
data=image.to_bytes(),
59+
mime_type="image/png",
60+
),
61+
genai_types.Part.from_text(text=query),
62+
],
63+
role="user",
64+
)
65+
generate_content_response = self._client.models.generate_content(
66+
model=f"models/{_extract_model_id(model_choice)}",
67+
contents=content,
68+
config={
69+
"response_mime_type": "application/json",
70+
"response_schema": _response_schema,
71+
"system_instruction": SYSTEM_PROMPT_GET,
72+
},
73+
)
74+
json_str = generate_content_response.text
75+
if json_str is None:
76+
raise QueryNoResponseError(
77+
message="No response from the model", query=query
78+
)
79+
try:
80+
return _response_schema.model_validate_json(json_str).root
81+
except ValidationError as e:
82+
error_message = str(e.errors())
83+
raise QueryUnexpectedResponseError(
84+
message=f"Unexpected response from the model: {error_message}",
85+
query=query,
86+
response=json_str,
87+
) from e
88+
except RecursionError as e:
89+
error_message = (
90+
"Recursive response schemas are not supported by AskUiGoogleGenAiApi"
91+
)
92+
raise NotImplementedError(error_message) from e

src/askui/models/model_router.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
from askui.locators.serializers import AskUiLocatorSerializer, VlmLocatorSerializer
88
from askui.models.anthropic.messages_api import AnthropicMessagesApi
99
from askui.models.askui.ai_element_utils import AiElementCollection
10+
from askui.models.askui.get_model import AskUiGetModel
11+
from askui.models.askui.google_genai_api import AskUiGoogleGenAiApi
1012
from askui.models.askui.model_router import AskUiModelRouter
1113
from askui.models.exceptions import ModelNotFoundError, ModelTypeMismatchError
1214
from askui.models.huggingface.spaces_api import HFSpacesHandler
@@ -57,6 +59,10 @@ def anthropic_facade() -> ModelFacade:
5759
locate_model=messages_api,
5860
)
5961

62+
@functools.cache
63+
def askui_google_genai_api() -> AskUiGoogleGenAiApi:
64+
return AskUiGoogleGenAiApi()
65+
6066
@functools.cache
6167
def askui_inference_api() -> AskUiInferenceApi:
6268
return AskUiInferenceApi(
@@ -72,6 +78,13 @@ def askui_model_router() -> AskUiModelRouter:
7278
inference_api=askui_inference_api(),
7379
)
7480

81+
@functools.cache
82+
def askui_get_model() -> AskUiGetModel:
83+
return AskUiGetModel(
84+
google_genai_api=askui_google_genai_api(),
85+
inference_api=askui_inference_api(),
86+
)
87+
7588
@functools.cache
7689
def askui_facade() -> ModelFacade:
7790
computer_agent = Agent(
@@ -80,7 +93,7 @@ def askui_facade() -> ModelFacade:
8093
)
8194
return ModelFacade(
8295
act_model=computer_agent,
83-
get_model=askui_inference_api(),
96+
get_model=askui_get_model(),
8497
locate_model=askui_model_router(),
8598
)
8699

@@ -93,6 +106,8 @@ def hf_spaces_handler() -> HFSpacesHandler:
93106
return {
94107
ModelName.ANTHROPIC__CLAUDE__3_5__SONNET__20241022: anthropic_facade,
95108
ModelName.ASKUI: askui_facade,
109+
ModelName.ASKUI__GEMINI__2_5__FLASH: askui_google_genai_api,
110+
ModelName.ASKUI__GEMINI__2_5__PRO: askui_google_genai_api,
96111
ModelName.ASKUI__AI_ELEMENT: askui_model_router,
97112
ModelName.ASKUI__COMBO: askui_model_router,
98113
ModelName.ASKUI__OCR: askui_model_router,

src/askui/models/models.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,15 @@ class ModelName(str, Enum):
2727

2828
ANTHROPIC__CLAUDE__3_5__SONNET__20241022 = "anthropic-claude-3-5-sonnet-20241022"
2929
ASKUI = "askui"
30+
ASKUI__GEMINI__2_5__FLASH = "askui/gemini-2.5-flash"
31+
ASKUI__GEMINI__2_5__PRO = "askui/gemini-2.5-pro"
3032
ASKUI__AI_ELEMENT = "askui-ai-element"
3133
ASKUI__COMBO = "askui-combo"
3234
ASKUI__OCR = "askui-ocr"
3335
ASKUI__PTA = "askui-pta"
3436
CLAUDE__SONNET__4__20250514 = "claude-sonnet-4-20250514"
37+
GEMINI__2_5__FLASH = "gemini-2.5-flash"
38+
GEMINI__2_5__PRO = "gemini-2.5-pro"
3539
HF__SPACES__ASKUI__PTA_1 = "AskUI/PTA-1"
3640
HF__SPACES__OS_COPILOT__OS_ATLAS_BASE_7B = "OS-Copilot/OS-Atlas-Base-7B"
3741
HF__SPACES__QWEN__QWEN2_VL_2B_INSTRUCT = "Qwen/Qwen2-VL-2B-Instruct"

src/askui/utils/image_utils.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,16 @@ def to_base64(self) -> str:
410410
"""
411411
return image_to_base64(image=self.root)
412412

413+
def to_bytes(self) -> bytes:
414+
"""Convert the image to bytes.
415+
416+
Returns:
417+
bytes: The image as bytes.
418+
"""
419+
img_byte_arr = io.BytesIO()
420+
self.root.save(img_byte_arr, format="PNG")
421+
return img_byte_arr.getvalue()
422+
413423

414424
__all__ = [
415425
"load_image",

0 commit comments

Comments
 (0)