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.
Files changed (64) hide show
  1. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/PKG-INFO +2 -1
  2. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/api.py +9 -0
  3. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/auth_router.py +4 -6
  4. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/config.py +6 -0
  5. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/conversation_router.py +7 -1
  6. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/conversation_service.py +43 -6
  7. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/models.py +10 -0
  8. openhands_agent_server-1.28.1/openhands/agent_server/openai/models.py +59 -0
  9. openhands_agent_server-1.28.1/openhands/agent_server/openai/router.py +111 -0
  10. openhands_agent_server-1.28.1/openhands/agent_server/openai/service.py +472 -0
  11. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/persistence/models.py +2 -2
  12. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/profiles_router.py +26 -16
  13. openhands_agent_server-1.28.1/openhands/agent_server/py.typed +0 -0
  14. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/settings_router.py +12 -8
  15. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands_agent_server.egg-info/PKG-INFO +2 -1
  16. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands_agent_server.egg-info/SOURCES.txt +8 -0
  17. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands_agent_server.egg-info/requires.txt +1 -0
  18. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/pyproject.toml +2 -1
  19. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/__init__.py +0 -0
  20. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/__main__.py +0 -0
  21. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/_secrets_exposure.py +0 -0
  22. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/bash_router.py +0 -0
  23. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/bash_service.py +0 -0
  24. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/cloud_proxy_router.py +0 -0
  25. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/conversation_lease.py +0 -0
  26. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/dependencies.py +0 -0
  27. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/desktop_router.py +0 -0
  28. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/desktop_service.py +0 -0
  29. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/docker/Dockerfile +0 -0
  30. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/docker/build.py +0 -0
  31. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/docker/wallpaper.svg +0 -0
  32. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/env_parser.py +0 -0
  33. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/event_router.py +0 -0
  34. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/event_service.py +0 -0
  35. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/file_router.py +0 -0
  36. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/git_router.py +0 -0
  37. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/hooks_router.py +0 -0
  38. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/hooks_service.py +0 -0
  39. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/llm_router.py +0 -0
  40. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/logging_config.py +0 -0
  41. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/mcp_router.py +0 -0
  42. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/middleware.py +0 -0
  43. /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
  44. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/openapi.py +0 -0
  45. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/persistence/__init__.py +0 -0
  46. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/persistence/store.py +0 -0
  47. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/pub_sub.py +0 -0
  48. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/server_details_router.py +0 -0
  49. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/skills_router.py +0 -0
  50. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/skills_service.py +0 -0
  51. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/sockets.py +0 -0
  52. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/tool_preload_service.py +0 -0
  53. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/tool_router.py +0 -0
  54. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/utils.py +0 -0
  55. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/vscode_extensions/openhands-settings/extension.js +0 -0
  56. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/vscode_extensions/openhands-settings/package.json +0 -0
  57. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/vscode_router.py +0 -0
  58. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/vscode_service.py +0 -0
  59. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/workspace_router.py +0 -0
  60. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands/agent_server/workspaces_router.py +0 -0
  61. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands_agent_server.egg-info/dependency_links.txt +0 -0
  62. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands_agent_server.egg-info/entry_points.txt +0 -0
  63. {openhands_agent_server-1.27.1 → openhands_agent_server-1.28.1}/openhands_agent_server.egg-info/top_level.txt +0 -0
  64. {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.27.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
@@ -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. Short enough that a stolen cookie isn't a
27
- # long-lived credential; long enough that a user previewing artifacts in
28
- # canvas isn't constantly re-authing. The cookie auto-renews on every call
29
- # to POST /api/auth/workspace-session, which the canvas frontend can do on
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.
@@ -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
- info, is_new = await conversation_service.start_conversation(request)
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
- fork_stored = StoredConversation(
923
- id=fork_conv_id,
924
- agent=fork_agent,
925
- workspace=fork_workspace,
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(
@@ -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`` only.
293
- Missing ``misc_settings`` defaults to an empty dict.
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(name: ProfileName) -> ProfileMutationResponse:
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
- # Update active_profile if the renamed profile was the active one
249
- if name != body.new_name:
250
- config = get_config(request)
251
- settings_store = get_settings_store(config)
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)"
@@ -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``, and/or
174
- ``misc_settings_diff`` for incremental updates. All three are deep-merged;
175
- nested objects merge recursively, and a ``null`` value **inside a nested
176
- map deletes that entry** the "unset" primitive that lets a client
177
- remove a single map key without round-tripping the whole map. To drop one
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, or misc_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.27.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
@@ -3,6 +3,7 @@ alembic>=1.13
3
3
  docker<8,>=7.1
4
4
  fastapi>=0.104
5
5
  openhands-sdk
6
+ openai<3,>=2.33.0
6
7
  pydantic>=2
7
8
  sqlalchemy>=2
8
9
  uvicorn>=0.31.1
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "openhands-agent-server"
3
- version = "1.27.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",