Skip to content
Merged
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
16 changes: 16 additions & 0 deletions src/kai_client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
KaiTimeoutError,
)
from kai_client.models import (
AgentSettings,
ApprovalResponse,
Chat,
ChatDetail,
ChatRequest,
Expand All @@ -51,18 +53,24 @@
RequestContext,
SSEEvent,
StepStartEvent,
Suggestion,
SuggestionsResponse,
TextEvent,
TextPart,
ToolApproval,
ToolApprovalRequestEvent,
ToolApprovalResponsePart,
ToolCallEvent,
ToolCallPart,
ToolInfo,
ToolOutputErrorEvent,
ToolResultPart,
ToolsListResponse,
UnknownEvent,
UsageEvent,
UsageInfo,
UsageResponse,
UserAgentSettings,
Vote,
VoteRequest,
)
Expand Down Expand Up @@ -111,6 +119,14 @@
"HistoryResponse",
"Vote",
"ErrorResponse",
"ApprovalResponse",
"UsageResponse",
"AgentSettings",
"UserAgentSettings",
"ToolInfo",
"ToolsListResponse",
"Suggestion",
"SuggestionsResponse",
# SSE models
"SSEEvent",
"TextEvent",
Expand Down
221 changes: 217 additions & 4 deletions src/kai_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from contextlib import asynccontextmanager
from json import JSONDecodeError
from types import TracebackType
from typing import Any, AsyncIterator, Optional
from typing import Any, AsyncIterator, Optional, Union

import httpx

Expand All @@ -15,6 +15,8 @@
raise_for_error_response,
)
from kai_client.models import (
AgentSettings,
ApprovalResponse,
Chat,
ChatDetail,
ChatRequest,
Expand All @@ -25,14 +27,22 @@
PingResponse,
RequestContext,
SSEEvent,
SuggestionsResponse,
TextPart,
ToolInfo,
ToolResultPart,
ToolsListResponse,
UsageResponse,
UserAgentSettings,
Vote,
VoteRequest,
)
from kai_client.sse import parse_sse_stream
from kai_client.types import VisibilityType, VoteType

# Sentinel for "caller did not pass this argument" — distinct from None (explicit clear).
_UNSET: Any = object()


def _normalize_visibility(visibility: str | VisibilityType) -> str:
"""Convert visibility to string value."""
Expand Down Expand Up @@ -461,6 +471,11 @@ async def send_tool_approval_response(
"""
Respond to a tool approval request (Vercel AI SDK v6 approval flow).

.. deprecated::
Use :meth:`submit_approval` instead. This method uses the old
Vercel AI SDK v6 protocol (fetch-chat → mutate-message → re-POST)
which is not supported by the kai-agent backend.

This works by fetching the chat, finding the assistant message that
contains the tool call requiring approval, updating its state to
``approval-responded``, and re-sending the updated assistant message
Expand Down Expand Up @@ -570,6 +585,10 @@ async def approve_tool(
"""
Approve a pending tool call and continue the stream.

.. deprecated::
Use :meth:`submit_approval` instead. This method uses the old
Vercel AI SDK v6 protocol not supported by the kai-agent backend.

Args:
chat_id: The chat session ID.
approval_id: The approval ID from the tool-call event's approval.id field.
Expand Down Expand Up @@ -602,6 +621,11 @@ async def reject_tool(
"""
Reject a pending tool call and continue the stream.

.. deprecated::
Use :meth:`submit_approval` with ``approved=False`` instead. This
method uses the old Vercel AI SDK v6 protocol not supported by the
kai-agent backend.

Args:
chat_id: The chat session ID.
approval_id: The approval ID from the tool-call event's approval.id field.
Expand Down Expand Up @@ -636,9 +660,10 @@ async def send_tool_result(
Send a tool result (legacy method, prefer send_tool_approval_response).

.. deprecated::
Use send_tool_approval_response / approve_tool / reject_tool instead.
This method uses the old tool-result protocol which can cause empty
user message errors with the Vercel AI SDK v6 backend.
Use :meth:`submit_approval` for the kai-agent backend.
Use send_tool_approval_response / approve_tool / reject_tool for the
old Vercel AI SDK v6 backend. This method uses the old tool-result
protocol which can cause empty user message errors.

Args:
chat_id: The chat session ID.
Expand Down Expand Up @@ -743,6 +768,10 @@ async def resume_stream(self, chat_id: str) -> AsyncIterator[SSEEvent]:
"""
Resume a chat stream if one is available.

.. deprecated::
The kai-agent backend does not expose a stream-resume endpoint.
This method is only functional against older backends.

This can be used to reconnect to an ongoing stream after a disconnect.

Args:
Expand All @@ -769,6 +798,45 @@ async def delete_chat(self, chat_id: str) -> None:
"""
await self._request("DELETE", "/api/chat", params={"id": chat_id})

async def submit_approval(
self,
chat_id: str,
tool_use_id: str,
approved: bool,
*,
reason: Optional[str] = None,
updated_input: Optional[dict[str, Any]] = None,
answers: Optional[dict[str, Any]] = None,
) -> ApprovalResponse:
"""
Approve or deny a pending tool call (kai-agent backend).

This is a non-streaming call that unblocks the live SSE stream still
running from the original send_message call. The toolCallId from the
``tool-input-available`` SSE event is the ``tool_use_id`` to pass here.

Args:
chat_id: The chat session ID.
tool_use_id: The toolCallId from the tool-input-available SSE event.
approved: Whether to approve (True) or deny (False) the tool call.
reason: Optional reason for the decision.
updated_input: Optional modified tool input (only used when approved).
answers: Optional answers for interactive tool questions.

Returns:
ApprovalResponse confirming the decision.
"""
payload: dict[str, Any] = {"toolUseId": tool_use_id, "approved": approved}
if reason is not None:
payload["reason"] = reason
if updated_input is not None:
payload["updatedInput"] = updated_input
if answers is not None:
payload["answers"] = answers

response = await self._request("POST", f"/api/chat/{chat_id}/approval", json=payload)
return ApprovalResponse.model_validate(response.json())

# =========================================================================
# History Endpoint
# =========================================================================
Expand Down Expand Up @@ -919,6 +987,151 @@ async def downvote(self, chat_id: str, message_id: str) -> Vote:
"""
return await self.vote(chat_id, message_id, VoteType.DOWN)

# =========================================================================
# Usage Endpoint
# =========================================================================

async def get_usage(self) -> UsageResponse:
"""
Get rate limit usage information for the current user and project.

Returns:
UsageResponse with messages used, limit, and reset date.
"""
response = await self._request("GET", "/api/usage")
return UsageResponse.model_validate(response.json())

# =========================================================================
# Settings Endpoints
# =========================================================================

async def get_settings(self) -> AgentSettings:
"""
Get project-level agent settings.

Returns:
AgentSettings with custom instructions for the project.
"""
response = await self._request("GET", "/api/settings")
return AgentSettings.model_validate(response.json())

async def update_settings(
self,
*,
custom_instructions: Union[str, None] = _UNSET,
) -> AgentSettings:
"""
Update project-level agent settings.

Only fields explicitly passed are included in the request — omitting a
parameter leaves the corresponding server value unchanged. Pass
``custom_instructions=None`` to explicitly clear instructions.

Args:
custom_instructions: Custom system prompt instructions for the project.
Pass ``None`` to clear. Omit to leave unchanged.

Returns:
Updated AgentSettings.
"""
payload: dict[str, Any] = {}
if custom_instructions is not _UNSET:
payload["customInstructions"] = custom_instructions

Comment thread
Matovidlo marked this conversation as resolved.
response = await self._request("PATCH", "/api/settings", json=payload)
return AgentSettings.model_validate(response.json())

async def get_user_settings(self) -> UserAgentSettings:
"""
Get user-level agent settings.

Returns:
UserAgentSettings with custom instructions and tool permissions.
"""
response = await self._request("GET", "/api/settings/user")
return UserAgentSettings.model_validate(response.json())

async def update_user_settings(
self,
*,
custom_instructions: Union[str, None] = _UNSET,
tool_permissions: Union[dict[str, str], None] = _UNSET,
) -> UserAgentSettings:
"""
Update user-level agent settings.

Only fields explicitly passed are included in the request — omitting a
parameter leaves the corresponding server value unchanged.

- Pass ``custom_instructions=None`` to clear instructions.
- Pass ``tool_permissions=None`` to reset all permissions to defaults.
- Pass a dict to ``tool_permissions`` to merge with existing values.

Valid permission values: ``"always_allow"``, ``"always_ask"``, ``"blocked"``.

Args:
custom_instructions: Custom system prompt instructions for this user.
Pass ``None`` to clear. Omit to leave unchanged.
tool_permissions: Dict mapping tool name to permission value.
Pass ``None`` to reset to defaults. Omit to leave unchanged.

Raises:
ValueError: If no fields are provided (nothing to update).

Returns:
Updated UserAgentSettings.
"""
payload: dict[str, Any] = {}
if custom_instructions is not _UNSET:
payload["customInstructions"] = custom_instructions
if tool_permissions is not _UNSET:
payload["toolPermissions"] = tool_permissions

if not payload:
raise ValueError(
"update_user_settings() requires at least one argument; "
"call with explicit values or None to clear individual fields."
)

Comment thread
Matovidlo marked this conversation as resolved.
response = await self._request("PATCH", "/api/settings/user", json=payload)
return UserAgentSettings.model_validate(response.json())

async def get_tools(self) -> ToolsListResponse:
"""
List all available MCP tools with their metadata.

Returns:
ToolsListResponse with tool names, descriptions, and read-only flags.
"""
response = await self._request("GET", "/api/settings/tools")
data = response.json()
tools = [ToolInfo.model_validate(t) for t in data.get("tools", [])]
return ToolsListResponse(tools=tools)

# =========================================================================
# Suggestions Endpoint
# =========================================================================

async def get_suggestions(
self,
context: str,
data: dict[str, Any],
) -> SuggestionsResponse:
"""
Generate contextual AI suggestions.

Args:
context: The context type — one of ``"dashboard"``,
``"job-detail"``, or ``"configuration-detail"``.
data: Context-specific data payload.

Returns:
SuggestionsResponse with a list of suggestions and session ID.
"""
payload: dict[str, Any] = {"context": context, "data": data}
response = await self._request("POST", "/api/suggestions", json=payload)
return SuggestionsResponse.model_validate(response.json())

# =========================================================================
# Convenience Methods
# =========================================================================
Expand Down
Loading
Loading