Skip to content

feat(sdk): introduce LLMProfileStore for persisted LLM configurations#1928

Open
VascoSch92 wants to merge 8 commits intomainfrom
llm_profiles
Open

feat(sdk): introduce LLMProfileStore for persisted LLM configurations#1928
VascoSch92 wants to merge 8 commits intomainfrom
llm_profiles

Conversation

@VascoSch92
Copy link
Contributor

@VascoSch92 VascoSch92 commented Feb 5, 2026

Summary

Following the discussion in #1882, this PR introduces a new class to manage persisted LLM profiles.

Could you please take a look at the thread locking logic? I am not an expert in this area and want to ensure it is implemented correctly.

Open question: Should we allow any filename for saved profiles, or should we restrict the choice of filenames?

Checklist

  • If the PR is changing/adding functionality, are there tests to reflect this?
  • If there is an example, have you run the example to make sure that it works?
  • If there are instructions on how to run the code, have you followed the instructions and made sure that it works?
  • If the feature is significant enough to require documentation, is there a PR open on the OpenHands/docs repository with the same branch name?
  • Is the github CI passing?

Agent Server images for this PR

GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server

Variants & Base Images

Variant Architectures Base Image Docs / Tags
java amd64, arm64 eclipse-temurin:17-jdk Link
python amd64, arm64 nikolaik/python-nodejs:python3.12-nodejs22 Link
golang amd64, arm64 golang:1.21-bookworm Link

Pull (multi-arch manifest)

# Each variant is a multi-arch manifest supporting both amd64 and arm64
docker pull ghcr.io/openhands/agent-server:e6466f4-python

Run

docker run -it --rm \
  -p 8000:8000 \
  --name agent-server-e6466f4-python \
  ghcr.io/openhands/agent-server:e6466f4-python

All tags pushed for this build

ghcr.io/openhands/agent-server:e6466f4-golang-amd64
ghcr.io/openhands/agent-server:e6466f4-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:e6466f4-golang-arm64
ghcr.io/openhands/agent-server:e6466f4-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:e6466f4-java-amd64
ghcr.io/openhands/agent-server:e6466f4-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:e6466f4-java-arm64
ghcr.io/openhands/agent-server:e6466f4-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:e6466f4-python-amd64
ghcr.io/openhands/agent-server:e6466f4-nikolaik_s_python-nodejs_tag_python3.12-nodejs22-amd64
ghcr.io/openhands/agent-server:e6466f4-python-arm64
ghcr.io/openhands/agent-server:e6466f4-nikolaik_s_python-nodejs_tag_python3.12-nodejs22-arm64
ghcr.io/openhands/agent-server:e6466f4-golang
ghcr.io/openhands/agent-server:e6466f4-java
ghcr.io/openhands/agent-server:e6466f4-python

About Multi-Architecture Support

  • Each variant tag (e.g., e6466f4-python) is a multi-arch manifest supporting both amd64 and arm64
  • Docker automatically pulls the correct architecture for your platform
  • Individual architecture tags (e.g., e6466f4-python-amd64) are also available if needed

@VascoSch92 VascoSch92 requested review from enyst and xingyaoww February 5, 2026 17:12
@VascoSch92 VascoSch92 added the enhancement New feature or request label Feb 5, 2026
@VascoSch92 VascoSch92 mentioned this pull request Feb 5, 2026
5 tasks
@github-actions
Copy link
Contributor

github-actions bot commented Feb 5, 2026

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-sdk/openhands/sdk
   __init__.py20290%64–65
openhands-sdk/openhands/sdk/llm
   llm_profile_store.py63296%51–52
TOTAL17031498970% 

Copy link
Collaborator

@all-hands-bot all-hands-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Summary

Overall, this is a well-implemented feature with good test coverage and solid thread-safety using filelock. The locking logic is correct and properly protects concurrent access. However, there is a critical security issue with path traversal that must be addressed.

Key Findings:

  • ✅ Thread locking implementation is correct and safe
  • ✅ Excellent test coverage including concurrency tests
  • ✅ Good use of atomic file operations
  • ⚠️ Critical: Path traversal vulnerability in filename handling
  • 💡 Consider additional filename validation

Regarding your open question about filename restrictions: Yes, you should restrict filenames to prevent path traversal attacks and filesystem compatibility issues. See inline comments for details.

self.base_dir = Path(base_dir) if base_dir is not None else _DEFAULT_PROFILE_DIR
# ensure directory existence
self.base_dir.mkdir(parents=True, exist_ok=True)
self._file_lock = FileLock(self.base_dir / ".profiles.lock")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Nit: The lock file is created inside base_dir, which works correctly but could theoretically be problematic if the entire directory is deleted by another process.

Optional improvement: Consider using a dedicated lock location:

lock_dir = Path.home() / ".openhands" / "locks"
lock_dir.mkdir(parents=True, exist_ok=True)
self._file_lock = FileLock(lock_dir / f"profiles_{hash(self.base_dir)}.lock")

However, the current approach is simpler and works well for the expected use case.

Comment on lines +96 to +103
with tempfile.NamedTemporaryFile(
mode="w", dir=self.base_dir, suffix=".tmp", delete=False
) as tmp:
tmp.write(profile_json)
tmp_path = Path(tmp.name)

Path.replace(tmp_path, profile_path)
logger.info(f"[Profile Store] Saved profile `{name}` at {profile_path}")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good: Atomic write pattern using temp file + replace is the right approach for reliability.

Minor note: If an exception occurs during llm.model_dump_json() or tmp.write(), the temp file might be left behind. This is acceptable because:

  1. It uses .tmp suffix making it easy to identify
  2. The lock is properly released (context manager)
  3. Leftover temp files don't affect correctness

Optional improvement: Add cleanup in a finally block if you want to be extra tidy:

tmp_path = None
try:
    with tempfile.NamedTemporaryFile(...) as tmp:
        tmp.write(profile_json)
        tmp_path = Path(tmp.name)
    Path.replace(tmp_path, profile_path)
finally:
    if tmp_path and tmp_path.exists() and not profile_path.exists():
        tmp_path.unlink(missing_ok=True)

But this adds complexity for minimal benefit.

Comment on lines +256 to +291
def test_delete_nonexistent_profile(profile_store: LLMProfileStore) -> None:
"""Test that deleting a nonexistent profile doesn't raise an error."""
profile_store.delete("nonexistent")


def test_concurrent_saves(tmp_path: Path) -> None:
"""Test that concurrent saves don't corrupt data."""
store = LLMProfileStore(base_dir=tmp_path)
num_threads = 10
results: list[int] = []
errors: list[tuple[int, Exception]] = []

def save_profile(index: int) -> None:
try:
llm = LLM(
usage_id=f"test-{index}",
model=f"model-{index}",
temperature=0.1 * index,
)
store.save(f"profile_{index}", llm)
results.append(index)
except Exception as e:
errors.append((index, e))

threads = [
threading.Thread(target=save_profile, args=(i,)) for i in range(num_threads)
]

for t in threads:
t.start()
for t in threads:
t.join()

assert len(errors) == 0, f"Errors occurred: {errors}"
assert len(results) == num_threads

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent: The concurrent tests are well-designed and properly verify thread-safety. This gives confidence that the locking implementation works correctly under concurrent access.

Good coverage of:

  • Concurrent writes
  • Mixed reads/writes
  • Proper error collection
  • Result verification

This addresses the core concern about thread safety!

@VascoSch92 VascoSch92 changed the title feat: introduce LLMProfileStore for persisted LLM configurations feat(sdk): introduce LLMProfileStore for persisted LLM configurations Feb 5, 2026
Copy link
Collaborator

@xingyaoww xingyaoww left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is overall in the right direction! @enyst what do you think?

@enyst
Copy link
Collaborator

enyst commented Feb 7, 2026

Looks great to me!

Open question: Should we allow any filename for saved profiles, or should we restrict the choice of filenames?

I think the nice UX here is:

  • keep it simple
  • don't overly restrict user ability naming/renaming

Profile filenames: claude-opus-4-5 (same as llm), but also gpt-5-proxy, gpt-5.2, oracle, Gemini-Flash, gemini_summarizer

  • no need for spaces
  • needs dots IMHO, but not ../stuff

Profile filename = profile ID, as the PR does.

(Optional, someday (tm): profile name as distinct from ID. We totally could, but don't need to, IMHO. The ID is user-set, they will name it whatever they find readable. Client apps can add more display niceties.)

@VascoSch92
Copy link
Contributor Author

VascoSch92 commented Feb 7, 2026

Looks great to me!

Open question: Should we allow any filename for saved profiles, or should we restrict the choice of filenames?

I think the nice UX here is:

  • keep it simple

  • don't overly restrict user ability naming/renaming

Profile filenames: claude-opus-4-5 (same as llm), but also gpt-5-proxy, gpt-5.2, oracle, Gemini-Flash, gemini_summarizer

  • no need for spaces

  • needs dots IMHO, but not ../stuff

Profile filename = profile ID, as the PR does.

(Optional, someday (tm): profile name as distinct from ID. We totally could, but don't need to, IMHO. The ID is user-set, they will name it whatever they find readable. Client apps can add more display niceties.)

Ok!

I will do the changes (already proposed by openhands ;)) and i will add an example because the class is in the public API.

@VascoSch92
Copy link
Contributor Author

  • example on how to use profile store
  • correct handle filename
  • test for invalid filename

@VascoSch92 VascoSch92 requested a review from xingyaoww February 8, 2026 09:46
@openhands-ai
Copy link

openhands-ai bot commented Feb 8, 2026

Looks like there are a few issues preventing this PR from being merged!

  • GitHub Actions are failing:
    • [Optional] Docs example

If you'd like me to help, just leave a comment, like

@OpenHands please fix the failing actions on PR #1928 at branch `llm_profiles`

Feel free to include any additional details that might help me get this PR into a better state.

You can manage your notification settings

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@VascoSch92 you should create a PR in OpenHands/docs for this new feature (with the same branch name as this one), so the docs workflow will be successful

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes it is on my todo list ;)

Copy link
Collaborator

@xingyaoww xingyaoww left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Thank you

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants