openhands-agent-server 1.27.1__tar.gz → 1.28.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/PKG-INFO +2 -1
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/api.py +9 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/auth_router.py +4 -6
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/config.py +6 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/conversation_router.py +7 -1
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/conversation_service.py +43 -6
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/models.py +10 -0
- openhands_agent_server-1.28.1/openhands/agent_server/openai/models.py +59 -0
- openhands_agent_server-1.28.1/openhands/agent_server/openai/router.py +111 -0
- openhands_agent_server-1.28.1/openhands/agent_server/openai/service.py +472 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/persistence/models.py +2 -2
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/profiles_router.py +26 -16
- openhands_agent_server-1.28.1/openhands/agent_server/py.typed +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/settings_router.py +12 -8
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands_agent_server.egg-info/PKG-INFO +2 -1
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands_agent_server.egg-info/SOURCES.txt +8 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands_agent_server.egg-info/requires.txt +1 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/pyproject.toml +2 -1
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/__init__.py +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/__main__.py +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/_secrets_exposure.py +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/bash_router.py +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/bash_service.py +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/cloud_proxy_router.py +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/conversation_lease.py +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/dependencies.py +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/desktop_router.py +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/desktop_service.py +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/docker/Dockerfile +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/docker/build.py +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/docker/wallpaper.svg +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/env_parser.py +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/event_router.py +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/event_service.py +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/file_router.py +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/git_router.py +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/hooks_router.py +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/hooks_service.py +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/llm_router.py +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/logging_config.py +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/mcp_router.py +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/middleware.py +0 -0
- /openhands_agent_server-1.27.1/openhands/agent_server/py.typed → /openhands_agent_server-1.28.1/openhands/agent_server/openai/__init__.py +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/openapi.py +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/persistence/__init__.py +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/persistence/store.py +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/pub_sub.py +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/server_details_router.py +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/skills_router.py +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/skills_service.py +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/sockets.py +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/tool_preload_service.py +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/tool_router.py +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/utils.py +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/vscode_extensions/openhands-settings/extension.js +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/vscode_extensions/openhands-settings/package.json +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/vscode_router.py +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/vscode_service.py +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/workspace_router.py +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/workspaces_router.py +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands_agent_server.egg-info/dependency_links.txt +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands_agent_server.egg-info/entry_points.txt +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands_agent_server.egg-info/top_level.txt +0 -0
- {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openhands-agent-server
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.28.1
|
|
4
4
|
Summary: OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent
|
|
5
5
|
Project-URL: Source, https://github.com/OpenHands/software-agent-sdk
|
|
6
6
|
Project-URL: Homepage, https://github.com/OpenHands/software-agent-sdk
|
|
@@ -12,6 +12,7 @@ Requires-Dist: alembic>=1.13
|
|
|
12
12
|
Requires-Dist: docker<8,>=7.1
|
|
13
13
|
Requires-Dist: fastapi>=0.104
|
|
14
14
|
Requires-Dist: openhands-sdk
|
|
15
|
+
Requires-Dist: openai<3,>=2.33.0
|
|
15
16
|
Requires-Dist: pydantic>=2
|
|
16
17
|
Requires-Dist: sqlalchemy>=2
|
|
17
18
|
Requires-Dist: uvicorn>=0.31.1
|
{openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/api.py
RENAMED
|
@@ -41,6 +41,10 @@ from openhands.agent_server.hooks_router import hooks_router
|
|
|
41
41
|
from openhands.agent_server.llm_router import llm_router
|
|
42
42
|
from openhands.agent_server.mcp_router import mcp_router
|
|
43
43
|
from openhands.agent_server.middleware import CORSDispatcher
|
|
44
|
+
from openhands.agent_server.openai.router import (
|
|
45
|
+
create_openai_api_key_dependency,
|
|
46
|
+
openai_router,
|
|
47
|
+
)
|
|
44
48
|
from openhands.agent_server.profiles_router import profiles_router
|
|
45
49
|
from openhands.agent_server.server_details_router import (
|
|
46
50
|
get_server_info,
|
|
@@ -319,6 +323,11 @@ def _add_api_routes(app: FastAPI, config: Config) -> None:
|
|
|
319
323
|
api_router.include_router(auth_router)
|
|
320
324
|
app.include_router(api_router)
|
|
321
325
|
|
|
326
|
+
openai_dependencies = []
|
|
327
|
+
if config.session_api_keys:
|
|
328
|
+
openai_dependencies.append(Depends(create_openai_api_key_dependency(config)))
|
|
329
|
+
app.include_router(openai_router, dependencies=openai_dependencies)
|
|
330
|
+
|
|
322
331
|
# Workspace static-file routes get their own auth group that accepts
|
|
323
332
|
# EITHER the X-Session-API-Key header OR the workspace session cookie.
|
|
324
333
|
# The cookie is required so that <iframe src> / <img src> embeds of
|
|
@@ -23,12 +23,10 @@ from openhands.agent_server.dependencies import WORKSPACE_SESSION_COOKIE_NAME
|
|
|
23
23
|
|
|
24
24
|
auth_router = APIRouter(prefix="/auth", tags=["Auth"])
|
|
25
25
|
|
|
26
|
-
# Cookie lifetime in seconds.
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
|
|
30
|
-
# load.
|
|
31
|
-
_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 8 # 8 hours
|
|
26
|
+
# Cookie lifetime in seconds. Set to effectively "never expire" — browsers
|
|
27
|
+
# (per RFC 6265bis, e.g. Chrome) clamp Max-Age to ~400 days regardless of
|
|
28
|
+
# the value sent, so this is the longest persistence the spec allows.
|
|
29
|
+
_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365 * 10 # 10 years
|
|
32
30
|
|
|
33
31
|
# Path scope: only sent on workspace-router URLs. Other /api/* endpoints
|
|
34
32
|
# never see the cookie.
|
{openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/config.py
RENAMED
|
@@ -132,6 +132,12 @@ class Config(BaseModel):
|
|
|
132
132
|
"The location of the directory where conversations and events are stored."
|
|
133
133
|
),
|
|
134
134
|
)
|
|
135
|
+
workspace_path: Path = Field(
|
|
136
|
+
default=Path("workspace/project"),
|
|
137
|
+
description=(
|
|
138
|
+
"Default workspace directory for conversations created by the server."
|
|
139
|
+
),
|
|
140
|
+
)
|
|
135
141
|
bash_events_dir: Path = Field(
|
|
136
142
|
default=Path("workspace/bash_events"),
|
|
137
143
|
description=(
|
|
@@ -41,6 +41,7 @@ from openhands.agent_server.models import (
|
|
|
41
41
|
)
|
|
42
42
|
from openhands.sdk import LLM, Agent, TextContent
|
|
43
43
|
from openhands.sdk.conversation.state import ConversationExecutionStatus
|
|
44
|
+
from openhands.sdk.tool.client_tool import ClientToolRegistrationError
|
|
44
45
|
from openhands.sdk.workspace import LocalWorkspace
|
|
45
46
|
from openhands.tools.preset.default import get_default_tools
|
|
46
47
|
|
|
@@ -194,7 +195,12 @@ async def start_conversation(
|
|
|
194
195
|
conversation_service: ConversationService = Depends(get_conversation_service),
|
|
195
196
|
) -> ConversationInfo:
|
|
196
197
|
"""Start a conversation in the local environment."""
|
|
197
|
-
|
|
198
|
+
try:
|
|
199
|
+
info, is_new = await conversation_service.start_conversation(request)
|
|
200
|
+
except ClientToolRegistrationError as e:
|
|
201
|
+
raise HTTPException(
|
|
202
|
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)
|
|
203
|
+
) from e
|
|
198
204
|
response.status_code = status.HTTP_201_CREATED if is_new else status.HTTP_200_OK
|
|
199
205
|
if not include_skills:
|
|
200
206
|
info = trim_conversation_response_skills(info)
|
|
@@ -5,7 +5,7 @@ from concurrent.futures import ThreadPoolExecutor
|
|
|
5
5
|
from contextlib import suppress
|
|
6
6
|
from dataclasses import dataclass, field
|
|
7
7
|
from pathlib import Path
|
|
8
|
-
from typing import TYPE_CHECKING, cast
|
|
8
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
9
9
|
from uuid import UUID, uuid4
|
|
10
10
|
|
|
11
11
|
import httpx
|
|
@@ -42,6 +42,7 @@ from openhands.sdk.event import MessageEvent
|
|
|
42
42
|
from openhands.sdk.event.conversation_state import ConversationStateUpdateEvent
|
|
43
43
|
from openhands.sdk.git.exceptions import GitCommandError, GitRepositoryError
|
|
44
44
|
from openhands.sdk.git.utils import run_git_command, validate_git_repository
|
|
45
|
+
from openhands.sdk.tool.client_tool import register_client_tools
|
|
45
46
|
from openhands.sdk.utils.cipher import Cipher
|
|
46
47
|
from openhands.sdk.workspace import LocalWorkspace
|
|
47
48
|
|
|
@@ -307,6 +308,7 @@ def _compose_conversation_info(
|
|
|
307
308
|
current_model_id=current_model_id,
|
|
308
309
|
available_models=available_models,
|
|
309
310
|
supports_runtime_model_switch=supports_runtime_model_switch,
|
|
311
|
+
client_tools=stored.client_tools,
|
|
310
312
|
)
|
|
311
313
|
|
|
312
314
|
|
|
@@ -638,6 +640,22 @@ class ConversationService:
|
|
|
638
640
|
conversation_id,
|
|
639
641
|
)
|
|
640
642
|
|
|
643
|
+
# Register client-defined tools (JSON specs, no Python code). The
|
|
644
|
+
# ClientTool *class* is registered statelessly; each tool's schema
|
|
645
|
+
# travels with the conversation via the returned Tool.params, so
|
|
646
|
+
# concurrent conversations never clobber each other's schemas.
|
|
647
|
+
if request.client_tools:
|
|
648
|
+
client_tool_specs = register_client_tools(request.client_tools)
|
|
649
|
+
# Inject Tool specs into the agent so _initialize() resolves them
|
|
650
|
+
existing_names = {t.name for t in request.agent.tools}
|
|
651
|
+
new_tools = [
|
|
652
|
+
ts for ts in client_tool_specs if ts.name not in existing_names
|
|
653
|
+
]
|
|
654
|
+
if new_tools:
|
|
655
|
+
request.agent = request.agent.model_copy(
|
|
656
|
+
update={"tools": [*request.agent.tools, *new_tools]}
|
|
657
|
+
)
|
|
658
|
+
|
|
641
659
|
# Register subagent definitions forwarded from the client
|
|
642
660
|
if request.agent_definitions:
|
|
643
661
|
_register_agent_definitions(
|
|
@@ -919,11 +937,25 @@ class ConversationService:
|
|
|
919
937
|
fork_conv.close()
|
|
920
938
|
|
|
921
939
|
# _start_event_service will resume from the persisted fork directory.
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
940
|
+
# Copy the source's stored metadata so request-level configuration
|
|
941
|
+
# (client_tools, tool_module_qualnames, agent_definitions, plugins,
|
|
942
|
+
# secrets, ...) is preserved on the fork, then override only the
|
|
943
|
+
# fork-specific fields. Without this, e.g. a fork of a client-tool
|
|
944
|
+
# conversation would lose ``client_tools`` in meta.json and be unable
|
|
945
|
+
# to re-register its tools after a server restart.
|
|
946
|
+
fork_overrides: dict[str, Any] = {
|
|
947
|
+
"id": fork_conv_id,
|
|
948
|
+
"agent": fork_agent,
|
|
949
|
+
"workspace": fork_workspace,
|
|
950
|
+
"title": title,
|
|
951
|
+
"created_at": utc_now(),
|
|
952
|
+
"updated_at": utc_now(),
|
|
953
|
+
}
|
|
954
|
+
if reset_metrics:
|
|
955
|
+
fork_overrides["metrics"] = None
|
|
956
|
+
if tags is not None:
|
|
957
|
+
fork_overrides["tags"] = tags
|
|
958
|
+
fork_stored = source_service.stored.model_copy(update=fork_overrides)
|
|
927
959
|
# If the service fails to start, clean up the orphaned persistence
|
|
928
960
|
# directory so we don't leave stale state on disk.
|
|
929
961
|
fork_dir = self.conversations_dir / fork_conv_id.hex
|
|
@@ -984,6 +1016,11 @@ class ConversationService:
|
|
|
984
1016
|
f"resuming conversation {stored.id}: "
|
|
985
1017
|
f"{list(stored.tool_module_qualnames.keys())}"
|
|
986
1018
|
)
|
|
1019
|
+
# Re-register client-defined tools when resuming. The agent's
|
|
1020
|
+
# persisted tool specs already carry each schema via params, so
|
|
1021
|
+
# we only need to (re-)register the ClientTool class per name.
|
|
1022
|
+
if stored.client_tools:
|
|
1023
|
+
register_client_tools(stored.client_tools)
|
|
987
1024
|
# Register agent definitions when resuming
|
|
988
1025
|
if stored.agent_definitions:
|
|
989
1026
|
_register_agent_definitions(
|
{openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/models.py
RENAMED
|
@@ -34,6 +34,7 @@ from openhands.sdk.security.confirmation_policy import (
|
|
|
34
34
|
ConfirmationPolicyBase,
|
|
35
35
|
NeverConfirm,
|
|
36
36
|
)
|
|
37
|
+
from openhands.sdk.tool.client_tool import ClientToolSpec
|
|
37
38
|
from openhands.sdk.utils import OpenHandsUUID, utc_now
|
|
38
39
|
from openhands.sdk.utils.models import (
|
|
39
40
|
DiscriminatedUnionMixin,
|
|
@@ -242,6 +243,15 @@ class ConversationInfo(_ConversationInfoBase):
|
|
|
242
243
|
...,
|
|
243
244
|
description="The agent running in the conversation.",
|
|
244
245
|
)
|
|
246
|
+
client_tools: list[ClientToolSpec] = Field(
|
|
247
|
+
default_factory=list,
|
|
248
|
+
description=(
|
|
249
|
+
"Client-defined tool specs registered for this conversation. "
|
|
250
|
+
"Surfaced so that a client re-attaching by conversation id can "
|
|
251
|
+
"register the dynamic ClientAction_* action types before syncing "
|
|
252
|
+
"persisted events, avoiding 'Unknown kind' deserialization errors."
|
|
253
|
+
),
|
|
254
|
+
)
|
|
245
255
|
|
|
246
256
|
|
|
247
257
|
class ConversationPage(BaseModel):
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Models for the OpenAI-compatible agent-server gateway."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from openai.types import CompletionUsage, Model
|
|
6
|
+
from openai.types.chat import ChatCompletion, ChatCompletionChunk
|
|
7
|
+
from openai.types.chat.chat_completion import Choice
|
|
8
|
+
from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice, ChoiceDelta
|
|
9
|
+
from openai.types.chat.chat_completion_message import ChatCompletionMessage
|
|
10
|
+
from pydantic import BaseModel, ConfigDict
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
OpenAIChatCompletionChoice = Choice
|
|
14
|
+
OpenAIChatCompletionChunk = ChatCompletionChunk
|
|
15
|
+
OpenAIChatCompletionChunkChoice = ChunkChoice
|
|
16
|
+
OpenAIChatCompletionChunkChoiceDelta = ChoiceDelta
|
|
17
|
+
OpenAIChatCompletionResponse = ChatCompletion
|
|
18
|
+
OpenAIModel = Model
|
|
19
|
+
OpenAIResponseMessage = ChatCompletionMessage
|
|
20
|
+
OpenAIUsage = CompletionUsage
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class OpenAIImageURL(BaseModel):
|
|
24
|
+
url: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class OpenAIContentPart(BaseModel):
|
|
28
|
+
type: str
|
|
29
|
+
text: str | None = None
|
|
30
|
+
image_url: OpenAIImageURL | str | None = None
|
|
31
|
+
|
|
32
|
+
model_config = ConfigDict(extra="ignore")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class OpenAIChatMessage(BaseModel):
|
|
36
|
+
role: Literal["system", "developer", "user", "assistant", "tool"]
|
|
37
|
+
content: str | list[OpenAIContentPart] | None = None
|
|
38
|
+
|
|
39
|
+
model_config = ConfigDict(extra="ignore")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class OpenAIStreamOptions(BaseModel):
|
|
43
|
+
include_usage: bool = False
|
|
44
|
+
|
|
45
|
+
model_config = ConfigDict(extra="ignore")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class OpenAIChatCompletionRequest(BaseModel):
|
|
49
|
+
model: str
|
|
50
|
+
messages: list[OpenAIChatMessage]
|
|
51
|
+
stream: bool = False
|
|
52
|
+
stream_options: OpenAIStreamOptions | None = None
|
|
53
|
+
|
|
54
|
+
model_config = ConfigDict(extra="ignore")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class OpenAIModelListResponse(BaseModel):
|
|
58
|
+
object: Literal["list"] = "list"
|
|
59
|
+
data: list[OpenAIModel]
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""OpenAI-compatible gateway routes for the agent server."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response, status
|
|
7
|
+
from fastapi.responses import StreamingResponse
|
|
8
|
+
from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer
|
|
9
|
+
|
|
10
|
+
from openhands.agent_server.config import Config
|
|
11
|
+
from openhands.agent_server.conversation_service import ConversationService
|
|
12
|
+
from openhands.agent_server.dependencies import get_conversation_service
|
|
13
|
+
from openhands.agent_server.openai.models import (
|
|
14
|
+
OpenAIChatCompletionRequest,
|
|
15
|
+
OpenAIChatCompletionResponse,
|
|
16
|
+
OpenAIModelListResponse,
|
|
17
|
+
)
|
|
18
|
+
from openhands.agent_server.openai.service import (
|
|
19
|
+
iter_openai_chat_completion_sse,
|
|
20
|
+
list_openai_models,
|
|
21
|
+
run_chat_completion,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
openai_router = APIRouter(tags=["OpenAI Compatibility"])
|
|
26
|
+
|
|
27
|
+
_SESSION_API_KEY_HEADER = APIKeyHeader(name="X-Session-API-Key", auto_error=False)
|
|
28
|
+
_AUTHORIZATION_HEADER = HTTPBearer(auto_error=False)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def create_openai_api_key_dependency(config: Config):
|
|
32
|
+
"""Accept the same session key through OpenHands and OpenAI auth shapes.
|
|
33
|
+
|
|
34
|
+
``X-Session-API-Key`` preserves compatibility with existing agent-server
|
|
35
|
+
clients, while ``Authorization: Bearer`` lets OpenAI-compatible clients use
|
|
36
|
+
their standard API-key header. Both forms validate against
|
|
37
|
+
``config.session_api_keys``; this does not introduce a second credential
|
|
38
|
+
system. When no session keys are configured, the local server remains
|
|
39
|
+
unauthenticated like the existing agent-server API.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def check_openai_api_key(
|
|
43
|
+
session_api_key: str | None = Depends(_SESSION_API_KEY_HEADER),
|
|
44
|
+
authorization: HTTPAuthorizationCredentials | None = Depends(
|
|
45
|
+
_AUTHORIZATION_HEADER
|
|
46
|
+
),
|
|
47
|
+
) -> None:
|
|
48
|
+
if not config.session_api_keys:
|
|
49
|
+
return
|
|
50
|
+
bearer_token = authorization.credentials if authorization else None
|
|
51
|
+
if session_api_key in config.session_api_keys:
|
|
52
|
+
return
|
|
53
|
+
if bearer_token in config.session_api_keys:
|
|
54
|
+
return
|
|
55
|
+
raise HTTPException(status.HTTP_401_UNAUTHORIZED)
|
|
56
|
+
|
|
57
|
+
return check_openai_api_key
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _get_config(request: Request) -> Config:
|
|
61
|
+
config = getattr(request.app.state, "config", None)
|
|
62
|
+
if not isinstance(config, Config):
|
|
63
|
+
raise HTTPException(
|
|
64
|
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
65
|
+
detail="Agent server config is not available",
|
|
66
|
+
)
|
|
67
|
+
return config
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@openai_router.get("/v1/models", response_model=OpenAIModelListResponse)
|
|
71
|
+
async def get_openai_models(request: Request) -> OpenAIModelListResponse:
|
|
72
|
+
_get_config(request)
|
|
73
|
+
return await list_openai_models()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@openai_router.post(
|
|
77
|
+
"/v1/chat/completions",
|
|
78
|
+
response_model=OpenAIChatCompletionResponse,
|
|
79
|
+
response_model_exclude_none=True,
|
|
80
|
+
)
|
|
81
|
+
async def create_chat_completion(
|
|
82
|
+
body: OpenAIChatCompletionRequest,
|
|
83
|
+
request: Request,
|
|
84
|
+
response: Response,
|
|
85
|
+
x_openhands_server_conversation_id: Annotated[
|
|
86
|
+
UUID | None, Header(alias="X-OpenHands-ServerConversation-ID")
|
|
87
|
+
] = None,
|
|
88
|
+
conversation_service: ConversationService = Depends(get_conversation_service),
|
|
89
|
+
) -> OpenAIChatCompletionResponse | StreamingResponse:
|
|
90
|
+
result = await run_chat_completion(
|
|
91
|
+
request=body.model_copy(update={"stream": False}) if body.stream else body,
|
|
92
|
+
config=_get_config(request),
|
|
93
|
+
conversation_service=conversation_service,
|
|
94
|
+
reusable_conversation_id=x_openhands_server_conversation_id,
|
|
95
|
+
)
|
|
96
|
+
conversation_id = str(result.conversation_id)
|
|
97
|
+
if body.stream:
|
|
98
|
+
include_usage = (
|
|
99
|
+
body.stream_options is not None and body.stream_options.include_usage
|
|
100
|
+
)
|
|
101
|
+
return StreamingResponse(
|
|
102
|
+
iter_openai_chat_completion_sse(
|
|
103
|
+
result.response,
|
|
104
|
+
include_usage=include_usage,
|
|
105
|
+
),
|
|
106
|
+
media_type="text/event-stream",
|
|
107
|
+
headers={"X-OpenHands-ServerConversation-ID": conversation_id},
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
response.headers["X-OpenHands-ServerConversation-ID"] = conversation_id
|
|
111
|
+
return result.response
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
"""Service logic for the OpenAI-compatible agent-server gateway."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from uuid import UUID, uuid4
|
|
9
|
+
|
|
10
|
+
from fastapi import HTTPException, status
|
|
11
|
+
|
|
12
|
+
from openhands.agent_server.config import Config
|
|
13
|
+
from openhands.agent_server.conversation_service import ConversationService
|
|
14
|
+
from openhands.agent_server.event_service import EventService
|
|
15
|
+
from openhands.agent_server.openai.models import (
|
|
16
|
+
OpenAIChatCompletionChoice,
|
|
17
|
+
OpenAIChatCompletionChunk,
|
|
18
|
+
OpenAIChatCompletionChunkChoice,
|
|
19
|
+
OpenAIChatCompletionChunkChoiceDelta,
|
|
20
|
+
OpenAIChatCompletionRequest,
|
|
21
|
+
OpenAIChatCompletionResponse,
|
|
22
|
+
OpenAIChatMessage,
|
|
23
|
+
OpenAIModel,
|
|
24
|
+
OpenAIModelListResponse,
|
|
25
|
+
OpenAIResponseMessage,
|
|
26
|
+
OpenAIUsage,
|
|
27
|
+
)
|
|
28
|
+
from openhands.agent_server.persistence import PersistedSettings, get_settings_store
|
|
29
|
+
from openhands.sdk import LLM, Message
|
|
30
|
+
from openhands.sdk.context.agent_context import AgentContext
|
|
31
|
+
from openhands.sdk.conversation.request import (
|
|
32
|
+
SendMessageRequest,
|
|
33
|
+
StartConversationRequest,
|
|
34
|
+
)
|
|
35
|
+
from openhands.sdk.conversation.state import (
|
|
36
|
+
ConversationExecutionStatus,
|
|
37
|
+
ConversationState,
|
|
38
|
+
)
|
|
39
|
+
from openhands.sdk.llm.llm_profile_store import LLMProfileStore
|
|
40
|
+
from openhands.sdk.llm.message import ImageContent, TextContent
|
|
41
|
+
from openhands.sdk.settings import ACPAgentSettings, OpenHandsAgentSettings
|
|
42
|
+
from openhands.sdk.workspace import LocalWorkspace
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_MODEL_PREFIX = "openhands_"
|
|
46
|
+
# Fixed gateway defaults are sufficient for the initial local-first endpoint;
|
|
47
|
+
# promote them to Config only if clients need deployment-specific tuning.
|
|
48
|
+
_GATEWAY_TIMEOUT_SECONDS = 120.0
|
|
49
|
+
_POLL_INTERVAL_SECONDS = 2
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class OpenAIChatCompletionResult:
|
|
54
|
+
response: OpenAIChatCompletionResponse
|
|
55
|
+
conversation_id: UUID
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _profile_name_from_model(model: str) -> str:
|
|
59
|
+
if model.startswith(_MODEL_PREFIX) and len(model) > len(_MODEL_PREFIX):
|
|
60
|
+
return model[len(_MODEL_PREFIX) :]
|
|
61
|
+
raise HTTPException(
|
|
62
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
63
|
+
detail=f"Unknown OpenHands model '{model}'. Use GET /v1/models.",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _load_profile_llm(profile_name: str, config: Config) -> LLM:
|
|
68
|
+
try:
|
|
69
|
+
return LLMProfileStore().load(profile_name, cipher=config.cipher)
|
|
70
|
+
except FileNotFoundError:
|
|
71
|
+
raise HTTPException(
|
|
72
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
73
|
+
detail=f"Profile '{profile_name}' not found",
|
|
74
|
+
)
|
|
75
|
+
except TimeoutError:
|
|
76
|
+
raise HTTPException(
|
|
77
|
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
78
|
+
detail="Profile store is busy. Please retry.",
|
|
79
|
+
)
|
|
80
|
+
except ValueError as exc:
|
|
81
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _append_system_suffix(existing: str | None, system_text: str) -> str:
|
|
85
|
+
return "\n\n".join(
|
|
86
|
+
text for text in ((existing or "").strip(), system_text.strip()) if text
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _with_profile_llm_and_system_text(
|
|
91
|
+
agent_settings: OpenHandsAgentSettings | ACPAgentSettings,
|
|
92
|
+
llm: LLM,
|
|
93
|
+
system_text: str,
|
|
94
|
+
) -> OpenHandsAgentSettings | ACPAgentSettings:
|
|
95
|
+
updated = agent_settings.model_copy(update={"llm": llm})
|
|
96
|
+
if not system_text:
|
|
97
|
+
return updated
|
|
98
|
+
|
|
99
|
+
if isinstance(updated, OpenHandsAgentSettings):
|
|
100
|
+
context = updated.agent_context
|
|
101
|
+
suffix = _append_system_suffix(context.system_message_suffix, system_text)
|
|
102
|
+
return updated.model_copy(
|
|
103
|
+
update={
|
|
104
|
+
"agent_context": context.model_copy(
|
|
105
|
+
update={"system_message_suffix": suffix}
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
context = updated.agent_context or AgentContext()
|
|
111
|
+
suffix = _append_system_suffix(context.system_message_suffix, system_text)
|
|
112
|
+
return updated.model_copy(
|
|
113
|
+
update={
|
|
114
|
+
"agent_context": context.model_copy(
|
|
115
|
+
update={"system_message_suffix": suffix}
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _content_to_sdk_parts(
|
|
122
|
+
message: OpenAIChatMessage,
|
|
123
|
+
) -> list[TextContent | ImageContent]:
|
|
124
|
+
content = message.content
|
|
125
|
+
if content is None:
|
|
126
|
+
return []
|
|
127
|
+
if isinstance(content, str):
|
|
128
|
+
return [TextContent(text=content)]
|
|
129
|
+
|
|
130
|
+
parts: list[TextContent | ImageContent] = []
|
|
131
|
+
for part in content:
|
|
132
|
+
if part.type == "text":
|
|
133
|
+
if part.text:
|
|
134
|
+
parts.append(TextContent(text=part.text))
|
|
135
|
+
continue
|
|
136
|
+
if part.type == "image_url":
|
|
137
|
+
if isinstance(part.image_url, str):
|
|
138
|
+
image_url = part.image_url
|
|
139
|
+
elif part.image_url is not None:
|
|
140
|
+
image_url = part.image_url.url
|
|
141
|
+
else:
|
|
142
|
+
raise HTTPException(
|
|
143
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
144
|
+
detail="image_url content part is missing a url",
|
|
145
|
+
)
|
|
146
|
+
parts.append(ImageContent(image_urls=[image_url]))
|
|
147
|
+
continue
|
|
148
|
+
raise HTTPException(
|
|
149
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
150
|
+
detail=f"Unsupported content part type: {part.type}",
|
|
151
|
+
)
|
|
152
|
+
return parts
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _message_text(message: OpenAIChatMessage) -> str:
|
|
156
|
+
text_parts: list[str] = []
|
|
157
|
+
for part in _content_to_sdk_parts(message):
|
|
158
|
+
if isinstance(part, TextContent):
|
|
159
|
+
text_parts.append(part.text)
|
|
160
|
+
return "\n".join(text_parts)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _latest_user_message(messages: list[OpenAIChatMessage]) -> OpenAIChatMessage:
|
|
164
|
+
for message in reversed(messages):
|
|
165
|
+
if message.role == "user":
|
|
166
|
+
return message
|
|
167
|
+
raise HTTPException(
|
|
168
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
169
|
+
detail="At least one user message is required",
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _system_text(messages: list[OpenAIChatMessage]) -> str:
|
|
174
|
+
text_parts: list[str] = []
|
|
175
|
+
for message in messages:
|
|
176
|
+
if message.role not in {"system", "developer"}:
|
|
177
|
+
continue
|
|
178
|
+
text = _message_text(message)
|
|
179
|
+
if text:
|
|
180
|
+
text_parts.append(text)
|
|
181
|
+
return "\n\n".join(text_parts)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _conversation_request(
|
|
185
|
+
*,
|
|
186
|
+
request: OpenAIChatCompletionRequest,
|
|
187
|
+
config: Config,
|
|
188
|
+
conversation_id: UUID | None,
|
|
189
|
+
) -> StartConversationRequest:
|
|
190
|
+
profile_name = _profile_name_from_model(request.model)
|
|
191
|
+
llm = _load_profile_llm(profile_name, config)
|
|
192
|
+
settings = get_settings_store(config).load() or PersistedSettings()
|
|
193
|
+
agent_settings = _with_profile_llm_and_system_text(
|
|
194
|
+
settings.agent_settings,
|
|
195
|
+
llm,
|
|
196
|
+
_system_text(request.messages),
|
|
197
|
+
)
|
|
198
|
+
user_message = _latest_user_message(request.messages)
|
|
199
|
+
conversation_settings = settings.conversation_settings.model_copy(
|
|
200
|
+
update={"agent_settings": agent_settings}
|
|
201
|
+
)
|
|
202
|
+
return conversation_settings.create_request(
|
|
203
|
+
StartConversationRequest,
|
|
204
|
+
workspace=LocalWorkspace(working_dir=config.workspace_path),
|
|
205
|
+
conversation_id=conversation_id,
|
|
206
|
+
initial_message=SendMessageRequest(
|
|
207
|
+
role="user",
|
|
208
|
+
content=_content_to_sdk_parts(user_message),
|
|
209
|
+
run=True,
|
|
210
|
+
),
|
|
211
|
+
autotitle=False,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# Keep this server-side waiter close to the gateway for readability. It follows
|
|
216
|
+
# the existing status-polling pattern, while RemoteConversation owns the richer
|
|
217
|
+
# client-side WebSocket fallback; we can consolidate if this grows in follow-up.
|
|
218
|
+
async def _wait_for_completion(
|
|
219
|
+
event_service: EventService,
|
|
220
|
+
*,
|
|
221
|
+
allow_existing_response: bool,
|
|
222
|
+
min_event_count: int | None = None,
|
|
223
|
+
timeout_seconds: float = _GATEWAY_TIMEOUT_SECONDS,
|
|
224
|
+
) -> ConversationExecutionStatus:
|
|
225
|
+
deadline = time.monotonic() + timeout_seconds
|
|
226
|
+
observed_run = False
|
|
227
|
+
last_status = ConversationExecutionStatus.IDLE
|
|
228
|
+
|
|
229
|
+
while True:
|
|
230
|
+
state = await event_service.get_state()
|
|
231
|
+
last_status = state.execution_status
|
|
232
|
+
enough_new_events = (
|
|
233
|
+
min_event_count is None or len(state.events) > min_event_count
|
|
234
|
+
)
|
|
235
|
+
if last_status == ConversationExecutionStatus.RUNNING:
|
|
236
|
+
observed_run = True
|
|
237
|
+
elif last_status.is_terminal() and (
|
|
238
|
+
allow_existing_response or observed_run or enough_new_events
|
|
239
|
+
):
|
|
240
|
+
return last_status
|
|
241
|
+
elif observed_run and enough_new_events:
|
|
242
|
+
return last_status
|
|
243
|
+
elif (
|
|
244
|
+
allow_existing_response
|
|
245
|
+
and enough_new_events
|
|
246
|
+
and await event_service.get_agent_final_response()
|
|
247
|
+
):
|
|
248
|
+
return last_status
|
|
249
|
+
|
|
250
|
+
if time.monotonic() >= deadline:
|
|
251
|
+
raise HTTPException(
|
|
252
|
+
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
|
253
|
+
detail="Agent run timed out",
|
|
254
|
+
)
|
|
255
|
+
await asyncio.sleep(_POLL_INTERVAL_SECONDS)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _raise_for_terminal_error(status_value: ConversationExecutionStatus) -> None:
|
|
259
|
+
if status_value in (
|
|
260
|
+
ConversationExecutionStatus.ERROR,
|
|
261
|
+
ConversationExecutionStatus.STUCK,
|
|
262
|
+
):
|
|
263
|
+
raise HTTPException(
|
|
264
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
265
|
+
detail=f"Agent run ended with status: {status_value.value}",
|
|
266
|
+
)
|
|
267
|
+
if status_value in (
|
|
268
|
+
ConversationExecutionStatus.PAUSED,
|
|
269
|
+
ConversationExecutionStatus.WAITING_FOR_CONFIRMATION,
|
|
270
|
+
):
|
|
271
|
+
raise HTTPException(
|
|
272
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
273
|
+
detail=f"Agent run ended with status: {status_value.value}",
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _openai_usage_from_state(state: ConversationState) -> OpenAIUsage:
|
|
278
|
+
token_usage = state.stats.get_combined_metrics().accumulated_token_usage
|
|
279
|
+
if token_usage is None:
|
|
280
|
+
return OpenAIUsage(
|
|
281
|
+
prompt_tokens=0,
|
|
282
|
+
completion_tokens=0,
|
|
283
|
+
total_tokens=0,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
prompt_tokens = token_usage.prompt_tokens
|
|
287
|
+
completion_tokens = token_usage.completion_tokens
|
|
288
|
+
return OpenAIUsage(
|
|
289
|
+
prompt_tokens=prompt_tokens,
|
|
290
|
+
completion_tokens=completion_tokens,
|
|
291
|
+
total_tokens=prompt_tokens + completion_tokens,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _openai_stream_event(payload: OpenAIChatCompletionChunk) -> str:
|
|
296
|
+
data = payload.model_dump(mode="json", exclude_none=True)
|
|
297
|
+
return f"data: {json.dumps(data, separators=(',', ':'))}\n\n"
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def iter_openai_chat_completion_sse(
|
|
301
|
+
response: OpenAIChatCompletionResponse,
|
|
302
|
+
*,
|
|
303
|
+
include_usage: bool,
|
|
304
|
+
) -> Iterator[str]:
|
|
305
|
+
created = int(response.created)
|
|
306
|
+
completion_id = response.id
|
|
307
|
+
model = response.model
|
|
308
|
+
content = response.choices[0].message.content
|
|
309
|
+
finish_reason = response.choices[0].finish_reason
|
|
310
|
+
|
|
311
|
+
yield _openai_stream_event(
|
|
312
|
+
OpenAIChatCompletionChunk(
|
|
313
|
+
id=completion_id,
|
|
314
|
+
object="chat.completion.chunk",
|
|
315
|
+
created=created,
|
|
316
|
+
model=model,
|
|
317
|
+
choices=[
|
|
318
|
+
OpenAIChatCompletionChunkChoice(
|
|
319
|
+
index=0,
|
|
320
|
+
delta=OpenAIChatCompletionChunkChoiceDelta(role="assistant"),
|
|
321
|
+
finish_reason=None,
|
|
322
|
+
)
|
|
323
|
+
],
|
|
324
|
+
)
|
|
325
|
+
)
|
|
326
|
+
yield _openai_stream_event(
|
|
327
|
+
OpenAIChatCompletionChunk(
|
|
328
|
+
id=completion_id,
|
|
329
|
+
object="chat.completion.chunk",
|
|
330
|
+
created=created,
|
|
331
|
+
model=model,
|
|
332
|
+
choices=[
|
|
333
|
+
OpenAIChatCompletionChunkChoice(
|
|
334
|
+
index=0,
|
|
335
|
+
delta=OpenAIChatCompletionChunkChoiceDelta(content=content),
|
|
336
|
+
finish_reason=None,
|
|
337
|
+
)
|
|
338
|
+
],
|
|
339
|
+
)
|
|
340
|
+
)
|
|
341
|
+
yield _openai_stream_event(
|
|
342
|
+
OpenAIChatCompletionChunk(
|
|
343
|
+
id=completion_id,
|
|
344
|
+
object="chat.completion.chunk",
|
|
345
|
+
created=created,
|
|
346
|
+
model=model,
|
|
347
|
+
choices=[
|
|
348
|
+
OpenAIChatCompletionChunkChoice(
|
|
349
|
+
index=0,
|
|
350
|
+
delta=OpenAIChatCompletionChunkChoiceDelta(),
|
|
351
|
+
finish_reason=finish_reason,
|
|
352
|
+
)
|
|
353
|
+
],
|
|
354
|
+
)
|
|
355
|
+
)
|
|
356
|
+
if include_usage:
|
|
357
|
+
yield _openai_stream_event(
|
|
358
|
+
OpenAIChatCompletionChunk(
|
|
359
|
+
id=completion_id,
|
|
360
|
+
object="chat.completion.chunk",
|
|
361
|
+
created=created,
|
|
362
|
+
model=model,
|
|
363
|
+
choices=[],
|
|
364
|
+
usage=response.usage,
|
|
365
|
+
)
|
|
366
|
+
)
|
|
367
|
+
yield "data: [DONE]\n\n"
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
async def list_openai_models() -> OpenAIModelListResponse:
|
|
371
|
+
try:
|
|
372
|
+
profiles = LLMProfileStore().list_summaries()
|
|
373
|
+
except TimeoutError:
|
|
374
|
+
raise HTTPException(
|
|
375
|
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
376
|
+
detail="Profile store is busy. Please retry.",
|
|
377
|
+
)
|
|
378
|
+
data = [
|
|
379
|
+
OpenAIModel(
|
|
380
|
+
id=f"{_MODEL_PREFIX}{profile['name']}",
|
|
381
|
+
object="model",
|
|
382
|
+
created=0,
|
|
383
|
+
owned_by="openhands",
|
|
384
|
+
)
|
|
385
|
+
for profile in profiles
|
|
386
|
+
if isinstance(profile.get("name"), str)
|
|
387
|
+
]
|
|
388
|
+
data.sort(key=lambda model: model.id)
|
|
389
|
+
return OpenAIModelListResponse(data=data)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
async def run_chat_completion(
|
|
393
|
+
*,
|
|
394
|
+
request: OpenAIChatCompletionRequest,
|
|
395
|
+
config: Config,
|
|
396
|
+
conversation_service: ConversationService,
|
|
397
|
+
reusable_conversation_id: UUID | None,
|
|
398
|
+
) -> OpenAIChatCompletionResult:
|
|
399
|
+
if request.stream:
|
|
400
|
+
# SSE streaming needs incremental agent-event forwarding; add it separately.
|
|
401
|
+
raise HTTPException(
|
|
402
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
403
|
+
detail="Streaming chat completions are not supported yet",
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
start_request = _conversation_request(
|
|
407
|
+
request=request,
|
|
408
|
+
config=config,
|
|
409
|
+
conversation_id=reusable_conversation_id,
|
|
410
|
+
)
|
|
411
|
+
event_service = None
|
|
412
|
+
conversation_id = reusable_conversation_id
|
|
413
|
+
min_event_count: int | None = None
|
|
414
|
+
|
|
415
|
+
if reusable_conversation_id is not None:
|
|
416
|
+
event_service = await conversation_service.get_event_service(
|
|
417
|
+
reusable_conversation_id
|
|
418
|
+
)
|
|
419
|
+
if event_service is not None:
|
|
420
|
+
min_event_count = len((await event_service.get_state()).events) + 1
|
|
421
|
+
user_message = _latest_user_message(request.messages)
|
|
422
|
+
await event_service.send_message(
|
|
423
|
+
Message(role="user", content=_content_to_sdk_parts(user_message)),
|
|
424
|
+
run=True,
|
|
425
|
+
)
|
|
426
|
+
allow_existing_response = event_service is None
|
|
427
|
+
|
|
428
|
+
if event_service is None:
|
|
429
|
+
conversation_info, _ = await conversation_service.start_conversation(
|
|
430
|
+
start_request
|
|
431
|
+
)
|
|
432
|
+
conversation_id = conversation_info.id
|
|
433
|
+
event_service = await conversation_service.get_event_service(
|
|
434
|
+
conversation_info.id
|
|
435
|
+
)
|
|
436
|
+
if event_service is None:
|
|
437
|
+
raise HTTPException(
|
|
438
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
439
|
+
detail="Conversation did not start",
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
status_value = await _wait_for_completion(
|
|
443
|
+
event_service,
|
|
444
|
+
allow_existing_response=allow_existing_response,
|
|
445
|
+
min_event_count=min_event_count,
|
|
446
|
+
)
|
|
447
|
+
_raise_for_terminal_error(status_value)
|
|
448
|
+
state = await event_service.get_state()
|
|
449
|
+
final_response = await event_service.get_agent_final_response()
|
|
450
|
+
# EventService.get_agent_final_response() returns final text from the SDK's
|
|
451
|
+
# get_agent_final_response(), so the gateway emits assistant text only.
|
|
452
|
+
response = OpenAIChatCompletionResponse(
|
|
453
|
+
id=f"chatcmpl-{uuid4().hex}",
|
|
454
|
+
object="chat.completion",
|
|
455
|
+
created=int(time.time()),
|
|
456
|
+
model=request.model,
|
|
457
|
+
choices=[
|
|
458
|
+
OpenAIChatCompletionChoice(
|
|
459
|
+
index=0,
|
|
460
|
+
finish_reason="stop",
|
|
461
|
+
message=OpenAIResponseMessage(
|
|
462
|
+
role="assistant",
|
|
463
|
+
content=final_response,
|
|
464
|
+
),
|
|
465
|
+
)
|
|
466
|
+
],
|
|
467
|
+
usage=_openai_usage_from_state(state),
|
|
468
|
+
)
|
|
469
|
+
assert conversation_id is not None
|
|
470
|
+
return OpenAIChatCompletionResult(
|
|
471
|
+
response=response, conversation_id=conversation_id
|
|
472
|
+
)
|
|
@@ -289,8 +289,8 @@ class PersistedSettings(BaseModel):
|
|
|
289
289
|
|
|
290
290
|
Schema-version history:
|
|
291
291
|
|
|
292
|
-
- **v1**: ``agent_settings`` + ``conversation_settings``
|
|
293
|
-
|
|
292
|
+
- **v1**: ``agent_settings`` + ``conversation_settings`` plus
|
|
293
|
+
``active_profile``.
|
|
294
294
|
- **v2** (current): adds the opaque ``misc_settings`` container.
|
|
295
295
|
"""
|
|
296
296
|
if not isinstance(data, dict):
|
|
@@ -105,6 +105,23 @@ def _has_api_key(llm: LLM) -> bool:
|
|
|
105
105
|
return bool(llm.api_key.get_secret_value().strip())
|
|
106
106
|
|
|
107
107
|
|
|
108
|
+
def _set_active_profile_if_matches(
|
|
109
|
+
request: Request, old_name: str, new_name: str | None
|
|
110
|
+
) -> bool:
|
|
111
|
+
config = get_config(request)
|
|
112
|
+
settings_store = get_settings_store(config)
|
|
113
|
+
settings = settings_store.load() or PersistedSettings()
|
|
114
|
+
if settings.active_profile != old_name:
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
def update_active(settings: PersistedSettings) -> PersistedSettings:
|
|
118
|
+
settings.active_profile = new_name
|
|
119
|
+
return settings
|
|
120
|
+
|
|
121
|
+
settings_store.update(update_active)
|
|
122
|
+
return True
|
|
123
|
+
|
|
124
|
+
|
|
108
125
|
@profiles_router.get("", response_model=ProfileListResponse)
|
|
109
126
|
async def list_profiles(request: Request) -> ProfileListResponse:
|
|
110
127
|
"""List all saved LLM profiles.
|
|
@@ -207,11 +224,15 @@ async def save_profile(
|
|
|
207
224
|
|
|
208
225
|
|
|
209
226
|
@profiles_router.delete("/{name}", response_model=ProfileMutationResponse)
|
|
210
|
-
async def delete_profile(
|
|
227
|
+
async def delete_profile(
|
|
228
|
+
request: Request, name: ProfileName
|
|
229
|
+
) -> ProfileMutationResponse:
|
|
211
230
|
"""Delete a saved profile (idempotent)."""
|
|
212
231
|
store = LLMProfileStore()
|
|
213
232
|
with _store_errors():
|
|
214
233
|
store.delete(name)
|
|
234
|
+
if _set_active_profile_if_matches(request, name, None):
|
|
235
|
+
logger.info(f"Cleared active_profile for deleted profile '{name}'")
|
|
215
236
|
logger.info(f"Deleted profile '{name}'")
|
|
216
237
|
return ProfileMutationResponse(name=name, message=f"Profile '{name}' deleted")
|
|
217
238
|
|
|
@@ -245,21 +266,10 @@ async def rename_profile(
|
|
|
245
266
|
detail=f"Profile '{body.new_name}' already exists",
|
|
246
267
|
)
|
|
247
268
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
settings = settings_store.load() or PersistedSettings()
|
|
253
|
-
|
|
254
|
-
if settings.active_profile == name:
|
|
255
|
-
new_name = body.new_name
|
|
256
|
-
|
|
257
|
-
def update_active(s: PersistedSettings) -> PersistedSettings:
|
|
258
|
-
s.active_profile = new_name
|
|
259
|
-
return s
|
|
260
|
-
|
|
261
|
-
settings_store.update(update_active)
|
|
262
|
-
logger.info(f"Updated active_profile from '{name}' to '{new_name}'")
|
|
269
|
+
if name != body.new_name and _set_active_profile_if_matches(
|
|
270
|
+
request, name, body.new_name
|
|
271
|
+
):
|
|
272
|
+
logger.info(f"Updated active_profile from '{name}' to '{body.new_name}'")
|
|
263
273
|
|
|
264
274
|
if name == body.new_name:
|
|
265
275
|
message = f"Profile '{name}' unchanged (same name)"
|
|
File without changes
|
|
@@ -160,6 +160,7 @@ async def get_settings(request: Request) -> SettingsResponse:
|
|
|
160
160
|
mode="json"
|
|
161
161
|
),
|
|
162
162
|
llm_api_key_is_set=settings.llm_api_key_is_set,
|
|
163
|
+
active_profile=settings.active_profile,
|
|
163
164
|
misc_settings=settings.misc_settings,
|
|
164
165
|
)
|
|
165
166
|
|
|
@@ -170,12 +171,12 @@ async def update_settings(
|
|
|
170
171
|
) -> SettingsResponse:
|
|
171
172
|
"""Update settings with partial changes.
|
|
172
173
|
|
|
173
|
-
Accepts ``agent_settings_diff``, ``conversation_settings_diff``,
|
|
174
|
-
``misc_settings_diff`` for incremental updates.
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
ACP env-var::
|
|
174
|
+
Accepts ``agent_settings_diff``, ``conversation_settings_diff``,
|
|
175
|
+
``misc_settings_diff``, and/or ``active_profile`` for incremental updates.
|
|
176
|
+
The three ``*_settings_diff`` fields are deep-merged; nested objects merge
|
|
177
|
+
recursively, and a ``null`` value **inside a nested map deletes that entry**
|
|
178
|
+
— the "unset" primitive that lets a client remove a single map key without
|
|
179
|
+
round-tripping the whole map. To drop one ACP env-var::
|
|
179
180
|
|
|
180
181
|
PATCH /api/settings
|
|
181
182
|
{"agent_settings_diff": {"acp_env": {"STALE_KEY": null}}}
|
|
@@ -203,14 +204,16 @@ async def update_settings(
|
|
|
203
204
|
store = get_settings_store(config)
|
|
204
205
|
|
|
205
206
|
update_data = payload.model_dump(exclude_none=True)
|
|
207
|
+
if "active_profile" in payload.model_fields_set:
|
|
208
|
+
update_data["active_profile"] = payload.active_profile
|
|
206
209
|
if not update_data:
|
|
207
210
|
# No updates provided - this is a client error
|
|
208
211
|
raise HTTPException(
|
|
209
212
|
status_code=400,
|
|
210
213
|
detail=(
|
|
211
214
|
"At least one of agent_settings_diff, "
|
|
212
|
-
"conversation_settings_diff,
|
|
213
|
-
"must be provided"
|
|
215
|
+
"conversation_settings_diff, misc_settings_diff, "
|
|
216
|
+
"or active_profile must be provided"
|
|
214
217
|
),
|
|
215
218
|
)
|
|
216
219
|
|
|
@@ -265,6 +268,7 @@ async def update_settings(
|
|
|
265
268
|
agent_settings=settings.agent_settings.model_dump(mode="json"),
|
|
266
269
|
conversation_settings=settings.conversation_settings.model_dump(mode="json"),
|
|
267
270
|
llm_api_key_is_set=settings.llm_api_key_is_set,
|
|
271
|
+
active_profile=settings.active_profile,
|
|
268
272
|
misc_settings=settings.misc_settings,
|
|
269
273
|
)
|
|
270
274
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openhands-agent-server
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.28.1
|
|
4
4
|
Summary: OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent
|
|
5
5
|
Project-URL: Source, https://github.com/OpenHands/software-agent-sdk
|
|
6
6
|
Project-URL: Homepage, https://github.com/OpenHands/software-agent-sdk
|
|
@@ -12,6 +12,7 @@ Requires-Dist: alembic>=1.13
|
|
|
12
12
|
Requires-Dist: docker<8,>=7.1
|
|
13
13
|
Requires-Dist: fastapi>=0.104
|
|
14
14
|
Requires-Dist: openhands-sdk
|
|
15
|
+
Requires-Dist: openai<3,>=2.33.0
|
|
15
16
|
Requires-Dist: pydantic>=2
|
|
16
17
|
Requires-Dist: sqlalchemy>=2
|
|
17
18
|
Requires-Dist: uvicorn>=0.31.1
|
|
@@ -45,6 +45,10 @@ pyproject.toml
|
|
|
45
45
|
./openhands/agent_server/docker/Dockerfile
|
|
46
46
|
./openhands/agent_server/docker/build.py
|
|
47
47
|
./openhands/agent_server/docker/wallpaper.svg
|
|
48
|
+
./openhands/agent_server/openai/__init__.py
|
|
49
|
+
./openhands/agent_server/openai/models.py
|
|
50
|
+
./openhands/agent_server/openai/router.py
|
|
51
|
+
./openhands/agent_server/openai/service.py
|
|
48
52
|
./openhands/agent_server/persistence/__init__.py
|
|
49
53
|
./openhands/agent_server/persistence/models.py
|
|
50
54
|
./openhands/agent_server/persistence/store.py
|
|
@@ -96,6 +100,10 @@ openhands/agent_server/workspaces_router.py
|
|
|
96
100
|
openhands/agent_server/docker/Dockerfile
|
|
97
101
|
openhands/agent_server/docker/build.py
|
|
98
102
|
openhands/agent_server/docker/wallpaper.svg
|
|
103
|
+
openhands/agent_server/openai/__init__.py
|
|
104
|
+
openhands/agent_server/openai/models.py
|
|
105
|
+
openhands/agent_server/openai/router.py
|
|
106
|
+
openhands/agent_server/openai/service.py
|
|
99
107
|
openhands/agent_server/persistence/__init__.py
|
|
100
108
|
openhands/agent_server/persistence/models.py
|
|
101
109
|
openhands/agent_server/persistence/store.py
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "openhands-agent-server"
|
|
3
|
-
version = "1.
|
|
3
|
+
version = "1.28.1"
|
|
4
4
|
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
|
|
5
5
|
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -10,6 +10,7 @@ dependencies = [
|
|
|
10
10
|
"docker>=7.1,<8",
|
|
11
11
|
"fastapi>=0.104",
|
|
12
12
|
"openhands-sdk",
|
|
13
|
+
"openai>=2.33.0,<3",
|
|
13
14
|
"pydantic>=2",
|
|
14
15
|
"sqlalchemy>=2",
|
|
15
16
|
"uvicorn>=0.31.1",
|
{openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/__init__.py
RENAMED
|
File without changes
|
{openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/__main__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/env_parser.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/git_router.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/llm_router.py
RENAMED
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/mcp_router.py
RENAMED
|
File without changes
|
{openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/middleware.py
RENAMED
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/openapi.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/pub_sub.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/sockets.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/utils.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|