Skip to content
Open
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
21 changes: 18 additions & 3 deletions openviking_cli/client/_http_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import json
import os
from dataclasses import asdict, is_dataclass
from pathlib import Path
from typing import Any, Dict

Expand Down Expand Up @@ -60,6 +61,20 @@
}


def _message_part_to_payload(part: Any) -> Any:
if not is_dataclass(part):
return part

serialized_part = asdict(part)
if serialized_part.get("type") != "image_url":
return serialized_part

image_url = {"url": serialized_part.get("url", "")}
if serialized_part.get("detail") is not None:
image_url["detail"] = serialized_part["detail"]
return {"type": "image_url", "image_url": image_url}


def _timeout_configured_outside_call() -> bool:
if os.getenv("OPENVIKING_TIMEOUT"):
return True
Expand Down Expand Up @@ -171,7 +186,7 @@ async def add_message(
session_id: str,
role: str,
content: str | None = None,
parts: list[dict] | None = None,
parts: list[Any] | None = None,
created_at: str | None = None,
peer_id: str | None = None,
telemetry: Any = False,
Expand All @@ -181,7 +196,7 @@ async def add_message(
) -> Dict[str, Any]:
payload: Dict[str, Any] = {"role": role}
if parts is not None:
payload["parts"] = parts
payload["parts"] = [_message_part_to_payload(part) for part in parts]
elif content is not None:
payload["content"] = content
else:
Expand Down Expand Up @@ -244,7 +259,7 @@ def add_message(
session_id: str,
role: str,
content: str | None = None,
parts: list[dict] | None = None,
parts: list[Any] | None = None,
created_at: str | None = None,
peer_id: str | None = None,
telemetry: Any = False,
Expand Down
24 changes: 19 additions & 5 deletions sdk/python/openviking_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import tempfile
import uuid
import zipfile
from dataclasses import asdict, is_dataclass
from enum import Enum
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Union
Expand Down Expand Up @@ -67,6 +68,19 @@
GATEWAY_TOKEN_HEADER = "X-Gateway-Token"


def _message_part_to_payload(part: Any) -> Any:
if not is_dataclass(part):
return part

serialized_part = asdict(part)
if serialized_part.get("type") != "image_url":
return serialized_part

image_url = {"url": serialized_part.get("url", "")}
if serialized_part.get("detail") is not None:
image_url["detail"] = serialized_part["detail"]
return {"type": "image_url", "image_url": image_url}


def _image_mime_type(file_name: str = "") -> str:
mime_type, _ = mimetypes.guess_type(file_name or "")
Expand Down Expand Up @@ -137,7 +151,7 @@ async def add_message(
self,
role: str,
content: str | None = None,
parts: list[dict] | None = None,
parts: list[Any] | None = None,
created_at: str | None = None,
peer_id: str | None = None,
turn_id: str | None = None,
Expand Down Expand Up @@ -213,7 +227,7 @@ def add_message(
self,
role: str,
content: str | None = None,
parts: list[dict] | None = None,
parts: list[Any] | None = None,
created_at: str | None = None,
peer_id: str | None = None,
turn_id: str | None = None,
Expand Down Expand Up @@ -1467,7 +1481,7 @@ async def add_message(
session_id: str,
role: str,
content: str | None = None,
parts: list[dict] | None = None,
parts: list[Any] | None = None,
created_at: str | None = None,
peer_id: str | None = None,
telemetry: Any = False,
Expand All @@ -1477,7 +1491,7 @@ async def add_message(
) -> Dict[str, Any]:
payload: Dict[str, Any] = {"role": role}
if parts is not None:
payload["parts"] = parts
payload["parts"] = [_message_part_to_payload(part) for part in parts]
elif content is not None:
payload["content"] = content
else:
Expand Down Expand Up @@ -2443,7 +2457,7 @@ def add_message(
session_id: str,
role: str,
content: str | None = None,
parts: list[dict] | None = None,
parts: list[Any] | None = None,
created_at: str | None = None,
peer_id: str | None = None,
telemetry: Any = False,
Expand Down
83 changes: 83 additions & 0 deletions sdk/python/tests/test_async_client_behaviors.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,38 @@
import inspect
import json
from dataclasses import dataclass
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch

import httpx
import pytest
from openviking_sdk import AsyncHTTPClient, SyncHTTPClient
from openviking_sdk.client import Session, SyncSession
from openviking_sdk.errors import NotFoundError


@dataclass
class DataclassTextPart:
text: str
type: str = "text"


@dataclass
class DataclassImagePart:
url: str
detail: str | None = None
type: str = "image_url"


@dataclass
class DataclassToolPart:
tool_id: str
tool_name: str
tool_input: dict | None = None
type: str = "tool"


def test_add_resource_signatures_keep_telemetry_position():
for func in (AsyncHTTPClient.add_resource, SyncHTTPClient.add_resource):
params = list(inspect.signature(func).parameters)
Expand Down Expand Up @@ -137,6 +161,65 @@ async def test_async_http_client_sends_message_semantics_and_turn_retention():
}


def test_sync_http_client_converts_dataclass_message_parts_to_payload():
request_payloads = []

def handle_request(request):
request_payloads.append(json.loads(request.content))
return httpx.Response(
200,
json={"status": "success", "result": {"message_id": "msg-1"}},
)

client = SyncHTTPClient(url="http://localhost:1933")
client._async_client._http = httpx.AsyncClient(
base_url="http://localhost:1933",
transport=httpx.MockTransport(handle_request),
)
try:
result = client.add_message(
"demo-session",
"user",
parts=[
DataclassTextPart(text="Hello world!"),
DataclassImagePart(
url="https://example.com/image.png",
detail="high",
),
DataclassToolPart(
tool_id="call-1",
tool_name="search",
tool_input={"query": "hello"},
),
],
)
finally:
client.close()

assert result == {"message_id": "msg-1"}
assert request_payloads == [
{
"role": "user",
"parts": [
{"text": "Hello world!", "type": "text"},
{
"type": "image_url",
"image_url": {
"url": "https://example.com/image.png",
"detail": "high",
},
},
{
"tool_id": "call-1",
"tool_name": "search",
"tool_input": {"query": "hello"},
"type": "tool",
},
],
}
]


@pytest.mark.asyncio
async def test_async_http_client_reindex_posts_content_reindex():
client = AsyncHTTPClient(url="http://localhost:1933")
Expand Down
54 changes: 54 additions & 0 deletions sdk/python/tests/test_main_package_exports.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import json
import sys
from pathlib import Path

import httpx

SDK_ROOT = Path(__file__).resolve().parents[1]
REPO_ROOT = Path(__file__).resolve().parents[3]

Expand Down Expand Up @@ -80,3 +83,54 @@ def test_openviking_http_client_preserves_legacy_exception_types():
assert exc.code == "CONFLICT"
else:
raise AssertionError("expected ConflictError")


def test_openviking_sync_http_client_converts_message_parts_to_payload():
_purge_openviking_modules()
import openviking
from openviking.message import ImagePart, TextPart, ToolPart

request_payloads = []

def handle_request(request):
request_payloads.append(json.loads(request.content))
return httpx.Response(
200,
json={"status": "success", "result": {"message_id": "msg-1"}},
)

client = openviking.SyncHTTPClient(url="http://localhost:1933")
client._async_client._http = httpx.AsyncClient(
base_url="http://localhost:1933",
transport=httpx.MockTransport(handle_request),
)
try:
result = client.add_message(
"demo-session",
"user",
parts=[
TextPart(text="Hello world!"),
ImagePart(url="https://example.com/image.png", detail="high"),
ToolPart(
tool_id="call-1",
tool_name="search",
tool_input={"query": "hello"},
),
],
)
finally:
client.close()

assert result == {"message_id": "msg-1"}
assert request_payloads[0]["role"] == "user"
assert request_payloads[0]["parts"][0] == {"text": "Hello world!", "type": "text"}
assert request_payloads[0]["parts"][1] == {
"type": "image_url",
"image_url": {
"url": "https://example.com/image.png",
"detail": "high",
},
}
assert request_payloads[0]["parts"][2]["type"] == "tool"
assert request_payloads[0]["parts"][2]["tool_id"] == "call-1"
assert request_payloads[0]["parts"][2]["tool_input"] == {"query": "hello"}