mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 91a77aee2d | |||
| b71f258f34 | |||
| e1c355c60f | |||
| 9d802fd7bd | |||
| 475ac9d29c | |||
| 9d6eb1c47e | |||
| 43d0385642 | |||
| ada86336a2 | |||
| 8439626ada | |||
| cf96ebe6f4 | |||
| 764226967e | |||
| e4a1684197 | |||
| 87644aa165 | |||
| 255386bbfc | |||
| 5f5a58c9bd | |||
| cc64e7ba45 | |||
| 3e1b841a99 | |||
| 54d3adc10f | |||
| a2be25b261 | |||
| 9e0731abfd | |||
| b7109122da | |||
| 172e5d46a1 | |||
| 3e6768c742 | |||
| cd95ce55bb | |||
| c6ce36d205 | |||
| 1323733eee | |||
| efefe72b46 | |||
| 606d7750fa | |||
| 336ddb030d | |||
| 1beb20746c | |||
| dc99292418 | |||
| 9445165a23 | |||
| 0c254f9376 | |||
| 1dbcc527e7 | |||
| 209dc72ac5 | |||
| b258c7a3b2 | |||
| 70968e8953 | |||
| a199e1d8a2 | |||
| e6d8283b6c | |||
| 23b0e96c67 | |||
| f6b04b6f51 | |||
| 25ca337c7e | |||
| 859a43d5f4 | |||
| a679468e1c | |||
| 5de44642eb | |||
| 03dea2d689 | |||
| 280285c0f9 | |||
| 8823566b51 | |||
| abd199ef9e | |||
| 28cdb80967 | |||
| 3ceb0646ee | |||
| c87c14a5a2 | |||
| d7907ee9a9 | |||
| e26e4cf09d | |||
| 8881f0b8af | |||
| d20b3606b4 | |||
| 769773ad95 | |||
| 5d308b6df3 | |||
| 4e0a985f34 | |||
| c6e728f76b |
@@ -1,53 +1,33 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
# This script updates the PR description with commands to run the PR locally
|
||||
# It adds both Docker and uvx commands
|
||||
|
||||
# Get the branch name for the PR
|
||||
BRANCH_NAME=$(gh pr view "$PR_NUMBER" --json headRefName --jq .headRefName)
|
||||
BRANCH_NAME=$(gh pr view $PR_NUMBER --json headRefName --jq .headRefName)
|
||||
|
||||
# Define the Docker command
|
||||
DOCKER_RUN_COMMAND="docker run -it --rm \
|
||||
-p 3000:3000 \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:${SHORT_SHA}-nikolaik \
|
||||
--name openhands-app-${SHORT_SHA} \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:${SHORT_SHA}"
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:$SHORT_SHA-nikolaik \
|
||||
--name openhands-app-$SHORT_SHA \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:$SHORT_SHA"
|
||||
|
||||
# Define the uvx command
|
||||
UVX_RUN_COMMAND="uvx --python 3.12 --from git+https://github.com/All-Hands-AI/OpenHands@${BRANCH_NAME} openhands"
|
||||
UVX_RUN_COMMAND="uvx --python 3.12 --from git+https://github.com/All-Hands-AI/OpenHands@$BRANCH_NAME openhands"
|
||||
|
||||
# Get the current PR body
|
||||
PR_BODY=$(gh pr view "$PR_NUMBER" --json body --jq .body)
|
||||
PR_BODY=$(gh pr view $PR_NUMBER --json body --jq .body)
|
||||
|
||||
# Prepare the new PR body with both commands
|
||||
if echo "$PR_BODY" | grep -q "To run this PR locally, use the following command:"; then
|
||||
# For existing PR descriptions, use a more robust approach
|
||||
# Split the PR body at the "To run this PR locally" section and replace everything after it
|
||||
BEFORE_SECTION=$(echo "$PR_BODY" | sed '/To run this PR locally, use the following command:/,$d')
|
||||
NEW_PR_BODY=$(cat <<EOF
|
||||
${BEFORE_SECTION}
|
||||
|
||||
To run this PR locally, use the following command:
|
||||
|
||||
GUI with Docker:
|
||||
\`\`\`
|
||||
${DOCKER_RUN_COMMAND}
|
||||
\`\`\`
|
||||
|
||||
CLI with uvx:
|
||||
\`\`\`
|
||||
${UVX_RUN_COMMAND}
|
||||
\`\`\`
|
||||
EOF
|
||||
)
|
||||
# For existing PR descriptions, replace the command section
|
||||
NEW_PR_BODY=$(echo "$PR_BODY" | sed "s|To run this PR locally, use the following command:.*\`\`\`|To run this PR locally, use the following command:\n\nGUI with Docker:\n\`\`\`\n$DOCKER_RUN_COMMAND\n\`\`\`\n\nCLI with uvx:\n\`\`\`\n$UVX_RUN_COMMAND\n\`\`\`|s")
|
||||
else
|
||||
# For new PR descriptions: use heredoc safely without indentation
|
||||
NEW_PR_BODY=$(cat <<EOF
|
||||
$PR_BODY
|
||||
# For new PR descriptions
|
||||
NEW_PR_BODY="${PR_BODY}
|
||||
|
||||
---
|
||||
|
||||
@@ -55,17 +35,15 @@ To run this PR locally, use the following command:
|
||||
|
||||
GUI with Docker:
|
||||
\`\`\`
|
||||
${DOCKER_RUN_COMMAND}
|
||||
$DOCKER_RUN_COMMAND
|
||||
\`\`\`
|
||||
|
||||
CLI with uvx:
|
||||
\`\`\`
|
||||
${UVX_RUN_COMMAND}
|
||||
\`\`\`
|
||||
EOF
|
||||
)
|
||||
$UVX_RUN_COMMAND
|
||||
\`\`\`"
|
||||
fi
|
||||
|
||||
# Update the PR description
|
||||
echo "Updating PR description with Docker and uvx commands"
|
||||
gh pr edit "$PR_NUMBER" --body "$NEW_PR_BODY"
|
||||
gh pr edit $PR_NUMBER --body "$NEW_PR_BODY"
|
||||
|
||||
@@ -48,11 +48,11 @@ jobs:
|
||||
- name: Build Environment
|
||||
run: make build
|
||||
- name: Run Unit Tests
|
||||
run: PYTHONPATH=".:$PYTHONPATH" poetry run pytest --forked -n auto -svv ./tests/unit
|
||||
run: poetry run pytest --forked -n auto -svv ./tests/unit
|
||||
- name: Run Runtime Tests with CLIRuntime
|
||||
run: PYTHONPATH=".:$PYTHONPATH" TEST_RUNTIME=cli poetry run pytest -svv tests/runtime/test_bash.py
|
||||
run: TEST_RUNTIME=cli poetry run pytest -svv tests/runtime/test_bash.py
|
||||
- name: Run E2E Tests
|
||||
run: PYTHONPATH=".:$PYTHONPATH" poetry run pytest -svv tests/e2e
|
||||
run: poetry run pytest -svv tests/e2e
|
||||
|
||||
# Run specific Windows python tests
|
||||
test-on-windows:
|
||||
@@ -77,11 +77,9 @@ jobs:
|
||||
- name: Run Windows unit tests
|
||||
run: poetry run pytest -svv tests/unit/test_windows_bash.py
|
||||
env:
|
||||
PYTHONPATH: ".;$env:PYTHONPATH"
|
||||
DEBUG: "1"
|
||||
- name: Run Windows runtime tests with LocalRuntime
|
||||
run: $env:TEST_RUNTIME="local"; poetry run pytest -svv tests/runtime/test_bash.py
|
||||
env:
|
||||
PYTHONPATH: ".;$env:PYTHONPATH"
|
||||
TEST_RUNTIME: local
|
||||
DEBUG: "1"
|
||||
|
||||
@@ -12,11 +12,11 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
stale-issue-message: 'This issue is stale because it has been open for 40 days with no activity. Remove the stale label or leave a comment, otherwise it will be closed in 10 days.'
|
||||
stale-pr-message: 'This PR is stale because it has been open for 40 days with no activity. Remove the stale label or leave a comment, otherwise it will be closed in 10 days.'
|
||||
days-before-stale: 40
|
||||
stale-issue-message: 'This issue is stale because it has been open for 30 days with no activity. Remove stale label or comment or this will be closed in 7 days.'
|
||||
stale-pr-message: 'This PR is stale because it has been open for 30 days with no activity. Remove stale label or comment or this will be closed in 7 days.'
|
||||
days-before-stale: 30
|
||||
exempt-issue-labels: 'roadmap'
|
||||
close-issue-message: 'This issue was automatically closed due to 50 days of inactivity. We do this to help keep the issues somewhat manageable and focus on active issues.'
|
||||
close-pr-message: 'This PR was closed because it had no activity for 50 days. If you feel this was closed in error, and you would like to continue the PR, please resubmit or let us know.'
|
||||
days-before-close: 10
|
||||
close-issue-message: 'This issue was closed because it has been stalled for over 30 days with no activity.'
|
||||
close-pr-message: 'This PR was closed because it has been stalled for over 30 days with no activity.'
|
||||
days-before-close: 7
|
||||
operations-per-run: 150
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# Consolidated Gemini Performance Test Suite
|
||||
|
||||
This document describes the consolidated and deduplicated test suite for investigating Gemini 2.5 Pro performance issues in OpenHands.
|
||||
|
||||
## 📁 Test Files Overview
|
||||
|
||||
### 1. `test_thinking_budget.py` - **PRIMARY THINKING/REASONING TEST**
|
||||
**Purpose**: Primary test for thinking budget and reasoning effort configurations
|
||||
**Features**:
|
||||
- Tests old vs new Google Generative AI APIs
|
||||
- Compares thinking budget configurations (128, 1024, 2048, 4096 tokens)
|
||||
- Tests reasoning_effort parameters via LiteLLM
|
||||
- Includes direct REST API calls for comparison
|
||||
- **User Preference**: This is the main file for thinking/reasoning tests
|
||||
|
||||
### 2. `test_litellm_comprehensive.py` - **COMPREHENSIVE LITELLM TEST**
|
||||
**Purpose**: Consolidated LiteLLM performance testing (replaces test_litellm_performance.py + test_openhands_litellm.py)
|
||||
**Features**:
|
||||
- Basic LiteLLM configurations (streaming, temperature, etc.)
|
||||
- OpenHands-style configuration and calls
|
||||
- Reasoning effort and thinking budget parameters
|
||||
- Comprehensive performance analysis and comparison
|
||||
- **Consolidation**: Combines functionality from 2 previous files
|
||||
|
||||
### 3. `test_native_gemini.py` - **NATIVE GOOGLE API TEST**
|
||||
**Purpose**: Tests native Google Generative AI library (like RooCode uses)
|
||||
**Features**:
|
||||
- Direct Google API calls without LiteLLM abstraction
|
||||
- Streaming and non-streaming tests
|
||||
- Performance comparison baseline
|
||||
- **Baseline**: Shows optimal performance without middleware
|
||||
|
||||
### 4. `test_openhands_gemini_fix.py` - **OPENHANDS FIX VERIFICATION**
|
||||
**Purpose**: Tests the actual OpenHands Gemini performance fix implementation
|
||||
**Features**:
|
||||
- Tests OpenHands with optimized thinking budget configuration
|
||||
- Verifies 2.5x speedup (from ~25s to ~10s)
|
||||
- Configuration inspection and validation
|
||||
- **Implementation**: Tests the actual fix we deployed
|
||||
|
||||
### 5. `run_performance_tests.py` - **TEST ORCHESTRATOR**
|
||||
**Purpose**: Runs all tests in sequence and provides comprehensive analysis
|
||||
**Features**:
|
||||
- Dependency checking
|
||||
- Sequential test execution
|
||||
- Performance metrics extraction
|
||||
- Comparative analysis across all test types
|
||||
- **Orchestrator**: Runs all tests and provides summary
|
||||
|
||||
## 🗑️ Removed Files (Redundant)
|
||||
|
||||
### Removed: `quick_test.py`
|
||||
- **Reason**: Very basic test, functionality covered by `test_native_gemini.py`
|
||||
- **Redundancy**: Simple native API test already in comprehensive native test
|
||||
|
||||
### Removed: `test_litellm_performance.py`
|
||||
- **Reason**: Merged into `test_litellm_comprehensive.py`
|
||||
- **Redundancy**: Basic LiteLLM configurations now in comprehensive test
|
||||
|
||||
### Removed: `test_openhands_litellm.py`
|
||||
- **Reason**: Merged into `test_litellm_comprehensive.py`
|
||||
- **Redundancy**: OpenHands-style calls now in comprehensive test
|
||||
|
||||
## 🎯 Test Suite Organization
|
||||
|
||||
```
|
||||
Performance Testing Hierarchy:
|
||||
├── run_performance_tests.py (Orchestrator)
|
||||
├── test_thinking_budget.py (Primary thinking/reasoning)
|
||||
├── test_litellm_comprehensive.py (All LiteLLM scenarios)
|
||||
├── test_native_gemini.py (Baseline performance)
|
||||
└── test_openhands_gemini_fix.py (Fix verification)
|
||||
```
|
||||
|
||||
## 🚀 Usage
|
||||
|
||||
### Run Individual Tests:
|
||||
```bash
|
||||
# Primary thinking/reasoning test
|
||||
python test_thinking_budget.py
|
||||
|
||||
# Comprehensive LiteLLM test
|
||||
python test_litellm_comprehensive.py
|
||||
|
||||
# Native API baseline
|
||||
python test_native_gemini.py
|
||||
|
||||
# OpenHands fix verification
|
||||
python test_openhands_gemini_fix.py
|
||||
```
|
||||
|
||||
### Run Complete Suite:
|
||||
```bash
|
||||
# Run all tests with analysis
|
||||
python run_performance_tests.py
|
||||
```
|
||||
|
||||
## 📊 Test Coverage
|
||||
|
||||
| Test Aspect | Primary Test File | Coverage |
|
||||
|-------------|------------------|----------|
|
||||
| **Thinking Budget** | `test_thinking_budget.py` | ✅ Complete |
|
||||
| **Reasoning Effort** | `test_thinking_budget.py` | ✅ Complete |
|
||||
| **LiteLLM Performance** | `test_litellm_comprehensive.py` | ✅ Complete |
|
||||
| **OpenHands Style** | `test_litellm_comprehensive.py` | ✅ Complete |
|
||||
| **Native API Baseline** | `test_native_gemini.py` | ✅ Complete |
|
||||
| **Fix Verification** | `test_openhands_gemini_fix.py` | ✅ Complete |
|
||||
| **Streaming vs Non-streaming** | All files | ✅ Complete |
|
||||
| **Parameter Variations** | All files | ✅ Complete |
|
||||
|
||||
## 🎉 Benefits of Consolidation
|
||||
|
||||
1. **Reduced Redundancy**: Eliminated duplicate test logic across 3 files
|
||||
2. **Better Organization**: Clear separation of concerns by test purpose
|
||||
3. **Easier Maintenance**: Single comprehensive test instead of multiple overlapping ones
|
||||
4. **User Preference**: `test_thinking_budget.py` as primary thinking/reasoning test
|
||||
5. **Complete Coverage**: All original functionality preserved and enhanced
|
||||
|
||||
## 🔧 Dependencies
|
||||
|
||||
- `litellm` - For LiteLLM testing
|
||||
- `google-generativeai` - For old Google API
|
||||
- `google-genai` - For new Google API with thinking budget
|
||||
- `openhands` - For OpenHands fix testing
|
||||
|
||||
All dependencies are checked by `run_performance_tests.py` before execution.
|
||||
@@ -0,0 +1,752 @@
|
||||
{
|
||||
"test_suite": "comprehensive_performance_analysis",
|
||||
"timestamp": 1753576041.7115579,
|
||||
"total_tests": 16,
|
||||
"successful_tests": 16,
|
||||
"thinking_budget_tests": {
|
||||
"test_type": "thinking_budget",
|
||||
"timestamp": 1753575753.837211,
|
||||
"total_configs": 7,
|
||||
"successful_configs": 7,
|
||||
"results": [
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 2.797980308532715,
|
||||
"step2_duration": 1.8835067749023438e-05,
|
||||
"step3_duration": 2.499279260635376,
|
||||
"total_duration": 5.2979230880737305,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670.0",
|
||||
"result_correct": false,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 0,
|
||||
"step3_response_length": 86,
|
||||
"message_count": 6,
|
||||
"config_name": "Old API (No Thinking)",
|
||||
"timestamp": 1753575680.1571221
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 1.8824458122253418,
|
||||
"step2_duration": 1.5384819507598877,
|
||||
"step3_duration": 2.318272113800049,
|
||||
"total_duration": 5.739390850067139,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 5,
|
||||
"step3_response_length": 160,
|
||||
"message_count": 6,
|
||||
"config_name": "New API - Thinking Budget: 128",
|
||||
"timestamp": 1753575685.896559
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 2.7450361251831055,
|
||||
"step2_duration": 1.0403151512145996,
|
||||
"step3_duration": 5.529464960098267,
|
||||
"total_duration": 9.314986944198608,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 36,
|
||||
"step3_response_length": 153,
|
||||
"message_count": 6,
|
||||
"config_name": "New API - Thinking Budget: 1024",
|
||||
"timestamp": 1753575695.211576
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 3.2801640033721924,
|
||||
"step2_duration": 1.226274013519287,
|
||||
"step3_duration": 5.528562068939209,
|
||||
"total_duration": 10.035185813903809,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 7,
|
||||
"step3_response_length": 131,
|
||||
"message_count": 6,
|
||||
"config_name": "New API - Thinking Budget: 4096",
|
||||
"timestamp": 1753575705.246801
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 4.210190773010254,
|
||||
"step2_duration": 7.360184669494629,
|
||||
"step3_duration": 9.522583961486816,
|
||||
"total_duration": 21.093040704727173,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 0,
|
||||
"step3_response_length": 283,
|
||||
"message_count": 6,
|
||||
"config_name": "LiteLLM - Reasoning Effort: Low",
|
||||
"timestamp": 1753575726.339884
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 3.9966609477996826,
|
||||
"step2_duration": 1.2283189296722412,
|
||||
"step3_duration": 15.889936923980713,
|
||||
"total_duration": 21.115014791488647,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 35,
|
||||
"step3_response_length": 0,
|
||||
"message_count": 6,
|
||||
"config_name": "LiteLLM - Reasoning Effort: High",
|
||||
"timestamp": 1753575747.454922
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 2.030133008956909,
|
||||
"step2_duration": 1.9902338981628418,
|
||||
"step3_duration": 2.3604180812835693,
|
||||
"total_duration": 6.380887031555176,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 34,
|
||||
"step3_response_length": 277,
|
||||
"message_count": 6,
|
||||
"config_name": "LiteLLM - Thinking Budget: 128",
|
||||
"timestamp": 1753575753.83583
|
||||
}
|
||||
]
|
||||
},
|
||||
"litellm_comprehensive_tests": {
|
||||
"test_type": "litellm_comprehensive",
|
||||
"timestamp": 1753575966.9497,
|
||||
"total_configs": 9,
|
||||
"successful_configs": 9,
|
||||
"results": [
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 3.1620140075683594,
|
||||
"step2_duration": 6.163906097412109,
|
||||
"step3_duration": 8.57595705986023,
|
||||
"total_duration": 17.901986122131348,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 0,
|
||||
"step3_response_length": 290,
|
||||
"message_count": 6,
|
||||
"config_name": "Basic LiteLLM",
|
||||
"timestamp": 1753575823.836127
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 2.643059253692627,
|
||||
"step2_duration": 4.244822978973389,
|
||||
"step3_duration": 8.579889059066772,
|
||||
"total_duration": 15.474514722824097,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 0,
|
||||
"step3_response_length": 0,
|
||||
"message_count": 6,
|
||||
"config_name": "LiteLLM with Streaming",
|
||||
"timestamp": 1753575839.3106902
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 3.299806833267212,
|
||||
"step2_duration": 4.562235116958618,
|
||||
"step3_duration": 9.42275094985962,
|
||||
"total_duration": 17.284837007522583,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 34,
|
||||
"step3_response_length": 288,
|
||||
"message_count": 6,
|
||||
"config_name": "OpenHands Style (No Stream)",
|
||||
"timestamp": 1753575856.595548
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 2.8680617809295654,
|
||||
"step2_duration": 4.986494064331055,
|
||||
"step3_duration": 11.908216714859009,
|
||||
"total_duration": 19.762842893600464,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 34,
|
||||
"step3_response_length": 303,
|
||||
"message_count": 6,
|
||||
"config_name": "OpenHands Style (Streaming)",
|
||||
"timestamp": 1753575876.358408
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 4.153742074966431,
|
||||
"step2_duration": 1.2760770320892334,
|
||||
"step3_duration": 10.748784065246582,
|
||||
"total_duration": 16.178749799728394,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 35,
|
||||
"step3_response_length": 0,
|
||||
"message_count": 6,
|
||||
"config_name": "Reasoning Effort: Low",
|
||||
"timestamp": 1753575892.5371861
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 4.199495792388916,
|
||||
"step2_duration": 11.224999904632568,
|
||||
"step3_duration": 6.673478841781616,
|
||||
"total_duration": 22.098058938980103,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 0,
|
||||
"step3_response_length": 280,
|
||||
"message_count": 6,
|
||||
"config_name": "Reasoning Effort: Medium",
|
||||
"timestamp": 1753575914.6352708
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 3.7451419830322266,
|
||||
"step2_duration": 1.131227970123291,
|
||||
"step3_duration": 12.550342082977295,
|
||||
"total_duration": 17.426751136779785,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 35,
|
||||
"step3_response_length": 306,
|
||||
"message_count": 6,
|
||||
"config_name": "Reasoning Effort: High",
|
||||
"timestamp": 1753575932.0620391
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 3.0755691528320312,
|
||||
"step2_duration": 3.7900118827819824,
|
||||
"step3_duration": 8.599286079406738,
|
||||
"total_duration": 15.464945077896118,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 34,
|
||||
"step3_response_length": 306,
|
||||
"message_count": 6,
|
||||
"config_name": "Thinking Budget: 128",
|
||||
"timestamp": 1753575947.527002
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 2.970345973968506,
|
||||
"step2_duration": 4.713220119476318,
|
||||
"step3_duration": 11.738292932510376,
|
||||
"total_duration": 19.421957969665527,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 34,
|
||||
"step3_response_length": 310,
|
||||
"message_count": 6,
|
||||
"config_name": "Thinking Budget: 1024",
|
||||
"timestamp": 1753575966.948982
|
||||
}
|
||||
]
|
||||
},
|
||||
"summary": {
|
||||
"all_results": [
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 2.797980308532715,
|
||||
"step2_duration": 1.8835067749023438e-05,
|
||||
"step3_duration": 2.499279260635376,
|
||||
"total_duration": 5.2979230880737305,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670.0",
|
||||
"result_correct": false,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 0,
|
||||
"step3_response_length": 86,
|
||||
"message_count": 6,
|
||||
"config_name": "Old API (No Thinking)",
|
||||
"timestamp": 1753575680.1571221
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 1.8824458122253418,
|
||||
"step2_duration": 1.5384819507598877,
|
||||
"step3_duration": 2.318272113800049,
|
||||
"total_duration": 5.739390850067139,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 5,
|
||||
"step3_response_length": 160,
|
||||
"message_count": 6,
|
||||
"config_name": "New API - Thinking Budget: 128",
|
||||
"timestamp": 1753575685.896559
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 2.7450361251831055,
|
||||
"step2_duration": 1.0403151512145996,
|
||||
"step3_duration": 5.529464960098267,
|
||||
"total_duration": 9.314986944198608,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 36,
|
||||
"step3_response_length": 153,
|
||||
"message_count": 6,
|
||||
"config_name": "New API - Thinking Budget: 1024",
|
||||
"timestamp": 1753575695.211576
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 3.2801640033721924,
|
||||
"step2_duration": 1.226274013519287,
|
||||
"step3_duration": 5.528562068939209,
|
||||
"total_duration": 10.035185813903809,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 7,
|
||||
"step3_response_length": 131,
|
||||
"message_count": 6,
|
||||
"config_name": "New API - Thinking Budget: 4096",
|
||||
"timestamp": 1753575705.246801
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 4.210190773010254,
|
||||
"step2_duration": 7.360184669494629,
|
||||
"step3_duration": 9.522583961486816,
|
||||
"total_duration": 21.093040704727173,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 0,
|
||||
"step3_response_length": 283,
|
||||
"message_count": 6,
|
||||
"config_name": "LiteLLM - Reasoning Effort: Low",
|
||||
"timestamp": 1753575726.339884
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 3.9966609477996826,
|
||||
"step2_duration": 1.2283189296722412,
|
||||
"step3_duration": 15.889936923980713,
|
||||
"total_duration": 21.115014791488647,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 35,
|
||||
"step3_response_length": 0,
|
||||
"message_count": 6,
|
||||
"config_name": "LiteLLM - Reasoning Effort: High",
|
||||
"timestamp": 1753575747.454922
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 2.030133008956909,
|
||||
"step2_duration": 1.9902338981628418,
|
||||
"step3_duration": 2.3604180812835693,
|
||||
"total_duration": 6.380887031555176,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 34,
|
||||
"step3_response_length": 277,
|
||||
"message_count": 6,
|
||||
"config_name": "LiteLLM - Thinking Budget: 128",
|
||||
"timestamp": 1753575753.83583
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 3.1620140075683594,
|
||||
"step2_duration": 6.163906097412109,
|
||||
"step3_duration": 8.57595705986023,
|
||||
"total_duration": 17.901986122131348,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 0,
|
||||
"step3_response_length": 290,
|
||||
"message_count": 6,
|
||||
"config_name": "Basic LiteLLM",
|
||||
"timestamp": 1753575823.836127
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 2.643059253692627,
|
||||
"step2_duration": 4.244822978973389,
|
||||
"step3_duration": 8.579889059066772,
|
||||
"total_duration": 15.474514722824097,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 0,
|
||||
"step3_response_length": 0,
|
||||
"message_count": 6,
|
||||
"config_name": "LiteLLM with Streaming",
|
||||
"timestamp": 1753575839.3106902
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 3.299806833267212,
|
||||
"step2_duration": 4.562235116958618,
|
||||
"step3_duration": 9.42275094985962,
|
||||
"total_duration": 17.284837007522583,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 34,
|
||||
"step3_response_length": 288,
|
||||
"message_count": 6,
|
||||
"config_name": "OpenHands Style (No Stream)",
|
||||
"timestamp": 1753575856.595548
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 2.8680617809295654,
|
||||
"step2_duration": 4.986494064331055,
|
||||
"step3_duration": 11.908216714859009,
|
||||
"total_duration": 19.762842893600464,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 34,
|
||||
"step3_response_length": 303,
|
||||
"message_count": 6,
|
||||
"config_name": "OpenHands Style (Streaming)",
|
||||
"timestamp": 1753575876.358408
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 4.153742074966431,
|
||||
"step2_duration": 1.2760770320892334,
|
||||
"step3_duration": 10.748784065246582,
|
||||
"total_duration": 16.178749799728394,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 35,
|
||||
"step3_response_length": 0,
|
||||
"message_count": 6,
|
||||
"config_name": "Reasoning Effort: Low",
|
||||
"timestamp": 1753575892.5371861
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 4.199495792388916,
|
||||
"step2_duration": 11.224999904632568,
|
||||
"step3_duration": 6.673478841781616,
|
||||
"total_duration": 22.098058938980103,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 0,
|
||||
"step3_response_length": 280,
|
||||
"message_count": 6,
|
||||
"config_name": "Reasoning Effort: Medium",
|
||||
"timestamp": 1753575914.6352708
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 3.7451419830322266,
|
||||
"step2_duration": 1.131227970123291,
|
||||
"step3_duration": 12.550342082977295,
|
||||
"total_duration": 17.426751136779785,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 35,
|
||||
"step3_response_length": 306,
|
||||
"message_count": 6,
|
||||
"config_name": "Reasoning Effort: High",
|
||||
"timestamp": 1753575932.0620391
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 3.0755691528320312,
|
||||
"step2_duration": 3.7900118827819824,
|
||||
"step3_duration": 8.599286079406738,
|
||||
"total_duration": 15.464945077896118,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 34,
|
||||
"step3_response_length": 306,
|
||||
"message_count": 6,
|
||||
"config_name": "Thinking Budget: 128",
|
||||
"timestamp": 1753575947.527002
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 2.970345973968506,
|
||||
"step2_duration": 4.713220119476318,
|
||||
"step3_duration": 11.738292932510376,
|
||||
"total_duration": 19.421957969665527,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 34,
|
||||
"step3_response_length": 310,
|
||||
"message_count": 6,
|
||||
"config_name": "Thinking Budget: 1024",
|
||||
"timestamp": 1753575966.948982
|
||||
}
|
||||
],
|
||||
"fastest_configs": [
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 2.797980308532715,
|
||||
"step2_duration": 1.8835067749023438e-05,
|
||||
"step3_duration": 2.499279260635376,
|
||||
"total_duration": 5.2979230880737305,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670.0",
|
||||
"result_correct": false,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 0,
|
||||
"step3_response_length": 86,
|
||||
"message_count": 6,
|
||||
"config_name": "Old API (No Thinking)",
|
||||
"timestamp": 1753575680.1571221
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 1.8824458122253418,
|
||||
"step2_duration": 1.5384819507598877,
|
||||
"step3_duration": 2.318272113800049,
|
||||
"total_duration": 5.739390850067139,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 5,
|
||||
"step3_response_length": 160,
|
||||
"message_count": 6,
|
||||
"config_name": "New API - Thinking Budget: 128",
|
||||
"timestamp": 1753575685.896559
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 2.030133008956909,
|
||||
"step2_duration": 1.9902338981628418,
|
||||
"step3_duration": 2.3604180812835693,
|
||||
"total_duration": 6.380887031555176,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 34,
|
||||
"step3_response_length": 277,
|
||||
"message_count": 6,
|
||||
"config_name": "LiteLLM - Thinking Budget: 128",
|
||||
"timestamp": 1753575753.83583
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 2.7450361251831055,
|
||||
"step2_duration": 1.0403151512145996,
|
||||
"step3_duration": 5.529464960098267,
|
||||
"total_duration": 9.314986944198608,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 36,
|
||||
"step3_response_length": 153,
|
||||
"message_count": 6,
|
||||
"config_name": "New API - Thinking Budget: 1024",
|
||||
"timestamp": 1753575695.211576
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 3.2801640033721924,
|
||||
"step2_duration": 1.226274013519287,
|
||||
"step3_duration": 5.528562068939209,
|
||||
"total_duration": 10.035185813903809,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 7,
|
||||
"step3_response_length": 131,
|
||||
"message_count": 6,
|
||||
"config_name": "New API - Thinking Budget: 4096",
|
||||
"timestamp": 1753575705.246801
|
||||
}
|
||||
],
|
||||
"slowest_configs": [
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 2.970345973968506,
|
||||
"step2_duration": 4.713220119476318,
|
||||
"step3_duration": 11.738292932510376,
|
||||
"total_duration": 19.421957969665527,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 34,
|
||||
"step3_response_length": 310,
|
||||
"message_count": 6,
|
||||
"config_name": "Thinking Budget: 1024",
|
||||
"timestamp": 1753575966.948982
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 2.8680617809295654,
|
||||
"step2_duration": 4.986494064331055,
|
||||
"step3_duration": 11.908216714859009,
|
||||
"total_duration": 19.762842893600464,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 34,
|
||||
"step3_response_length": 303,
|
||||
"message_count": 6,
|
||||
"config_name": "OpenHands Style (Streaming)",
|
||||
"timestamp": 1753575876.358408
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 4.210190773010254,
|
||||
"step2_duration": 7.360184669494629,
|
||||
"step3_duration": 9.522583961486816,
|
||||
"total_duration": 21.093040704727173,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 0,
|
||||
"step3_response_length": 283,
|
||||
"message_count": 6,
|
||||
"config_name": "LiteLLM - Reasoning Effort: Low",
|
||||
"timestamp": 1753575726.339884
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 3.9966609477996826,
|
||||
"step2_duration": 1.2283189296722412,
|
||||
"step3_duration": 15.889936923980713,
|
||||
"total_duration": 21.115014791488647,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 35,
|
||||
"step3_response_length": 0,
|
||||
"message_count": 6,
|
||||
"config_name": "LiteLLM - Reasoning Effort: High",
|
||||
"timestamp": 1753575747.454922
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 4.199495792388916,
|
||||
"step2_duration": 11.224999904632568,
|
||||
"step3_duration": 6.673478841781616,
|
||||
"total_duration": 22.098058938980103,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 0,
|
||||
"step3_response_length": 280,
|
||||
"message_count": 6,
|
||||
"config_name": "Reasoning Effort: Medium",
|
||||
"timestamp": 1753575914.6352708
|
||||
}
|
||||
],
|
||||
"performance_analysis": {
|
||||
"fastest_time": 5.2979230880737305,
|
||||
"slowest_time": 22.098058938980103,
|
||||
"average_time": 14.999442055821419,
|
||||
"median_time": 17.284837007522583,
|
||||
"total_successful_tests": 16,
|
||||
"success_rate": 100.0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,67 +7,6 @@ description: High level overview of the Graphical User Interface (GUI) in OpenHa
|
||||
|
||||
- [OpenHands is running](/usage/local-setup)
|
||||
|
||||
## Launching the GUI Server
|
||||
|
||||
### Using the CLI Command
|
||||
|
||||
You can launch the OpenHands GUI server directly from the command line using the `serve` command:
|
||||
|
||||
<Callout type="info">
|
||||
**Prerequisites**: You need to have the [OpenHands CLI installed](/usage/how-to/cli-mode) first, OR have `uv` installed and run `uvx --python 3.12 --from openhands-ai openhands serve`. Otherwise, you'll need to use Docker directly (see the [Docker section](#using-docker-directly) below).
|
||||
</Callout>
|
||||
|
||||
```bash
|
||||
openhands serve
|
||||
```
|
||||
|
||||
This command will:
|
||||
- Check that Docker is installed and running
|
||||
- Pull the required Docker images
|
||||
- Launch the OpenHands GUI server at http://localhost:3000
|
||||
- Use the same configuration directory (`~/.openhands`) as the CLI mode
|
||||
|
||||
#### Mounting Your Current Directory
|
||||
|
||||
To mount your current working directory into the GUI server container, use the `--mount-cwd` flag:
|
||||
|
||||
```bash
|
||||
openhands serve --mount-cwd
|
||||
```
|
||||
|
||||
This is useful when you want to work on files in your current directory through the GUI. The directory will be mounted at `/workspace` inside the container.
|
||||
|
||||
#### Using GPU Support
|
||||
|
||||
If you have NVIDIA GPUs and want to make them available to the OpenHands container, use the `--gpu` flag:
|
||||
|
||||
```bash
|
||||
openhands serve --gpu
|
||||
```
|
||||
|
||||
This will enable GPU support via nvidia-docker, mounting all available GPUs into the container. You can combine this with other flags:
|
||||
|
||||
```bash
|
||||
openhands serve --gpu --mount-cwd
|
||||
```
|
||||
|
||||
**Prerequisites for GPU support:**
|
||||
- NVIDIA GPU drivers must be installed on your host system
|
||||
- [NVIDIA Container Toolkit (nvidia-docker2)](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html) must be installed and configured
|
||||
|
||||
#### Requirements
|
||||
|
||||
Before using the `openhands serve` command, ensure that:
|
||||
- Docker is installed and running on your system
|
||||
- You have internet access to pull the required Docker images
|
||||
- Port 3000 is available on your system
|
||||
|
||||
The CLI will automatically check these requirements and provide helpful error messages if anything is missing.
|
||||
|
||||
### Using Docker Directly
|
||||
|
||||
Alternatively, you can run the GUI server using Docker directly. See the [local setup guide](/usage/local-setup) for detailed Docker instructions.
|
||||
|
||||
## Overview
|
||||
|
||||
### Initial Setup
|
||||
|
||||
@@ -18,7 +18,7 @@ Based on these findings and community feedback, these are the latest models that
|
||||
### Cloud / API-Based Models
|
||||
|
||||
- [anthropic/claude-sonnet-4-20250514](https://www.anthropic.com/api) (recommended)
|
||||
- [openai/gpt-5-2025-08-07](https://openai.com/api/) (recommended)
|
||||
- [openai/o4-mini](https://openai.com/index/introducing-o3-and-o4-mini/)
|
||||
- [gemini/gemini-2.5-pro](https://blog.google/technology/google-deepmind/gemini-model-thinking-updates-march-2025/)
|
||||
- [deepseek/deepseek-chat](https://api-docs.deepseek.com/)
|
||||
- [moonshot/kimi-k2-0711-preview](https://platform.moonshot.ai/docs/pricing/chat#generation-model-kimi-k2)
|
||||
|
||||
@@ -32,4 +32,4 @@ When running OpenHands, you'll need to set the following in the OpenHands UI thr
|
||||
|
||||
Pricing follows official API provider rates. [You can view model prices here.](https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json)
|
||||
|
||||
For `qwen3-coder-480b`, we charge the cheapest FP8 rate available on openrouter: \$0.4 per million input tokens and \$1.6 per million output tokens.
|
||||
For `qwen3-coder-480b`, we charge the cheapest FP8 rate available on openrouter: $0.4 per million input tokens and $1.6 per million output tokens.
|
||||
|
||||
@@ -66,30 +66,6 @@ A system with a modern processor and a minimum of **4GB RAM** is recommended to
|
||||
|
||||
### Start the App
|
||||
|
||||
#### Option 1: Using the CLI Launcher (Recommended)
|
||||
|
||||
If you have Python 3.12+ installed, you can use the CLI launcher for a simpler experience:
|
||||
|
||||
```bash
|
||||
# Install OpenHands
|
||||
pip install openhands-ai
|
||||
|
||||
# Launch the GUI server
|
||||
openhands serve
|
||||
|
||||
# Or with GPU support (requires nvidia-docker)
|
||||
openhands serve --gpu
|
||||
|
||||
# Or with current directory mounted
|
||||
openhands serve --mount-cwd
|
||||
```
|
||||
|
||||
Or using `uvx --python 3.12 --from openhands-ai openhands serve` if you have [uv](https://docs.astral.sh/uv/) installed.
|
||||
|
||||
This will automatically handle Docker requirements checking, image pulling, and launching the GUI server. The `--gpu` flag enables GPU support via nvidia-docker, and `--mount-cwd` mounts your current directory into the container.
|
||||
|
||||
#### Option 2: Using Docker Directly
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik
|
||||
|
||||
|
||||
@@ -18,8 +18,8 @@ from evaluation.utils.shared import (
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
OpenHandsConfig,
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
@@ -172,7 +172,7 @@ def process_instance(
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_evaluation_parser()
|
||||
parser = get_parser()
|
||||
parser.add_argument(
|
||||
'--answerer_model', '-a', default='gpt-3.5-turbo', help='answerer model'
|
||||
)
|
||||
|
||||
@@ -26,8 +26,8 @@ from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AgentConfig,
|
||||
OpenHandsConfig,
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
@@ -525,7 +525,7 @@ def commit0_setup(dataset: pd.DataFrame, repo_split: str) -> pd.DataFrame:
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_evaluation_parser()
|
||||
parser = get_parser()
|
||||
parser.add_argument(
|
||||
'--dataset',
|
||||
type=str,
|
||||
|
||||
@@ -31,8 +31,8 @@ from evaluation.utils.shared import (
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
OpenHandsConfig,
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
load_from_toml,
|
||||
)
|
||||
from openhands.core.config.utils import get_agent_config_arg
|
||||
@@ -294,7 +294,7 @@ Here is the task:
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_evaluation_parser()
|
||||
parser = get_parser()
|
||||
parser.add_argument(
|
||||
'--level',
|
||||
type=str,
|
||||
|
||||
@@ -20,8 +20,8 @@ from evaluation.utils.shared import (
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
OpenHandsConfig,
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
@@ -134,7 +134,7 @@ def process_instance(
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_evaluation_parser()
|
||||
parser = get_parser()
|
||||
parser.add_argument(
|
||||
'--hubs',
|
||||
type=str,
|
||||
|
||||
@@ -38,8 +38,8 @@ from evaluation.utils.shared import (
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
OpenHandsConfig,
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
@@ -312,7 +312,7 @@ Ok now its time to start solving the question. Good luck!
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_evaluation_parser()
|
||||
parser = get_parser()
|
||||
# data split must be one of 'gpqa_main', 'gqpa_diamond', 'gpqa_experts', 'gpqa_extended'
|
||||
parser.add_argument(
|
||||
'--data-split',
|
||||
|
||||
@@ -21,7 +21,7 @@ from evaluation.utils.shared import (
|
||||
from openhands.core.config import (
|
||||
LLMConfig,
|
||||
OpenHandsConfig,
|
||||
get_evaluation_parser,
|
||||
get_parser,
|
||||
load_openhands_config,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
@@ -167,7 +167,7 @@ def process_predictions(predictions_path: str):
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_evaluation_parser()
|
||||
parser = get_parser()
|
||||
parser.add_argument(
|
||||
'-s',
|
||||
'--eval-split',
|
||||
|
||||
@@ -30,8 +30,8 @@ from evaluation.utils.shared import (
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
OpenHandsConfig,
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
load_openhands_config,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
@@ -358,7 +358,7 @@ Be thorough in your exploration, testing, and reasoning. It's fine if your think
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_evaluation_parser()
|
||||
parser = get_parser()
|
||||
parser.add_argument(
|
||||
'-s',
|
||||
'--eval-split',
|
||||
|
||||
@@ -18,8 +18,8 @@ from evaluation.utils.shared import (
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
OpenHandsConfig,
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
@@ -267,7 +267,7 @@ def process_instance(
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_evaluation_parser()
|
||||
parser = get_parser()
|
||||
parser.add_argument(
|
||||
'--dataset',
|
||||
type=str,
|
||||
|
||||
@@ -23,8 +23,8 @@ from evaluation.utils.shared import (
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
OpenHandsConfig,
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
@@ -229,7 +229,7 @@ def process_instance(
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_evaluation_parser()
|
||||
parser = get_parser()
|
||||
|
||||
SUBSETS = [
|
||||
# Eurus subset: https://arxiv.org/abs/2404.02078
|
||||
|
||||
@@ -4,11 +4,7 @@ import pprint
|
||||
|
||||
import tqdm
|
||||
|
||||
from openhands.core.config import (
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
load_openhands_config,
|
||||
)
|
||||
from openhands.core.config import get_llm_config_arg, get_parser, load_openhands_config
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.llm.llm import LLM
|
||||
|
||||
@@ -115,7 +111,7 @@ def classify_error(llm: LLM, failed_case: dict) -> str:
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_evaluation_parser()
|
||||
parser = get_parser()
|
||||
parser.add_argument(
|
||||
'--json_file_path',
|
||||
type=str,
|
||||
|
||||
@@ -34,8 +34,8 @@ from evaluation.utils.shared import (
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
OpenHandsConfig,
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
load_openhands_config,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
@@ -273,7 +273,7 @@ def process_instance(instance: Any, metadata: EvalMetadata, reset_logger: bool =
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_evaluation_parser()
|
||||
parser = get_parser()
|
||||
parser.add_argument(
|
||||
'-s',
|
||||
'--eval-split',
|
||||
|
||||
@@ -30,7 +30,7 @@ from evaluation.utils.shared import (
|
||||
from openhands.core.config import (
|
||||
LLMConfig,
|
||||
OpenHandsConfig,
|
||||
get_evaluation_parser,
|
||||
get_parser,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime
|
||||
@@ -323,7 +323,7 @@ def process_instance(
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_evaluation_parser()
|
||||
parser = get_parser()
|
||||
parser.add_argument(
|
||||
'--input-file',
|
||||
type=str,
|
||||
|
||||
@@ -32,8 +32,8 @@ from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AgentConfig,
|
||||
OpenHandsConfig,
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
@@ -772,7 +772,7 @@ def filter_dataset(dataset: pd.DataFrame, filter_column: str) -> pd.DataFrame:
|
||||
|
||||
if __name__ == '__main__':
|
||||
# pdb.set_trace()
|
||||
parser = get_evaluation_parser()
|
||||
parser = get_parser()
|
||||
parser.add_argument(
|
||||
'--dataset',
|
||||
type=str,
|
||||
|
||||
@@ -21,8 +21,8 @@ from evaluation.utils.shared import (
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
OpenHandsConfig,
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
@@ -239,7 +239,7 @@ If the program uses some packages that are incompatible, please figure out alter
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_evaluation_parser()
|
||||
parser = get_parser()
|
||||
parser.add_argument(
|
||||
'--use-knowledge',
|
||||
type=str,
|
||||
|
||||
@@ -26,7 +26,7 @@ from evaluation.utils.shared import (
|
||||
from openhands.core.config import (
|
||||
LLMConfig,
|
||||
OpenHandsConfig,
|
||||
get_evaluation_parser,
|
||||
get_parser,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime
|
||||
@@ -353,7 +353,7 @@ def process_instance(
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_evaluation_parser()
|
||||
parser = get_parser()
|
||||
parser.add_argument(
|
||||
'--input-file',
|
||||
type=str,
|
||||
|
||||
@@ -43,8 +43,8 @@ from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AgentConfig,
|
||||
OpenHandsConfig,
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
from openhands.core.config.condenser_config import NoOpCondenserConfig
|
||||
from openhands.core.config.utils import get_condenser_config_arg
|
||||
@@ -732,7 +732,7 @@ def filter_dataset(dataset: pd.DataFrame, filter_column: str) -> pd.DataFrame:
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_evaluation_parser()
|
||||
parser = get_parser()
|
||||
parser.add_argument(
|
||||
'--dataset',
|
||||
type=str,
|
||||
|
||||
@@ -28,8 +28,8 @@ from evaluation.utils.shared import (
|
||||
)
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
from openhands.core.config.condenser_config import NoOpCondenserConfig
|
||||
from openhands.core.config.utils import get_condenser_config_arg
|
||||
@@ -201,7 +201,7 @@ def process_instance(
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_evaluation_parser()
|
||||
parser = get_parser()
|
||||
parser.add_argument(
|
||||
'--dataset',
|
||||
type=str,
|
||||
|
||||
@@ -31,8 +31,8 @@ from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AgentConfig,
|
||||
OpenHandsConfig,
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
@@ -644,7 +644,7 @@ SWEGYM_EXCLUDE_IDS = [
|
||||
]
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_evaluation_parser()
|
||||
parser = get_parser()
|
||||
parser.add_argument(
|
||||
'--dataset',
|
||||
type=str,
|
||||
|
||||
@@ -41,7 +41,7 @@ from evaluation.utils.shared import (
|
||||
reset_logger_for_multiprocessing,
|
||||
run_evaluation,
|
||||
)
|
||||
from openhands.core.config import OpenHandsConfig, SandboxConfig, get_evaluation_parser
|
||||
from openhands.core.config import OpenHandsConfig, SandboxConfig, get_parser
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime
|
||||
from openhands.events.action import CmdRunAction
|
||||
@@ -484,7 +484,7 @@ def count_and_log_fields(evaluated_predictions, fields, key):
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_evaluation_parser()
|
||||
parser = get_parser()
|
||||
parser.add_argument(
|
||||
'--input-file', type=str, required=True, help='Path to input predictions file'
|
||||
)
|
||||
|
||||
@@ -37,8 +37,8 @@ from openhands.core.config import (
|
||||
AgentConfig,
|
||||
OpenHandsConfig,
|
||||
SandboxConfig,
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
@@ -491,7 +491,7 @@ def prepare_dataset_pre(dataset: pd.DataFrame, filter_column: str) -> pd.DataFra
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_evaluation_parser()
|
||||
parser = get_parser()
|
||||
parser.add_argument(
|
||||
'--dataset',
|
||||
type=str,
|
||||
|
||||
@@ -18,8 +18,8 @@ from openhands.core.config import (
|
||||
LLMConfig,
|
||||
OpenHandsConfig,
|
||||
get_agent_config_arg,
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
from openhands.core.config.agent_config import AgentConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
@@ -197,7 +197,7 @@ def run_evaluator(
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_evaluation_parser()
|
||||
parser = get_parser()
|
||||
parser.add_argument(
|
||||
'--task-image-name',
|
||||
type=str,
|
||||
|
||||
@@ -19,8 +19,8 @@ from evaluation.utils.shared import (
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
OpenHandsConfig,
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
@@ -157,7 +157,7 @@ def process_instance(instance: Any, metadata: EvalMetadata, reset_logger: bool =
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_evaluation_parser()
|
||||
parser = get_parser()
|
||||
parser.add_argument(
|
||||
'--dataset',
|
||||
type=str,
|
||||
|
||||
@@ -31,8 +31,8 @@ from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AgentConfig,
|
||||
OpenHandsConfig,
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
@@ -565,7 +565,7 @@ SWEGYM_EXCLUDE_IDS = [
|
||||
]
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_evaluation_parser()
|
||||
parser = get_parser()
|
||||
parser.add_argument(
|
||||
'--dataset',
|
||||
type=str,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { FileService } from "#/api/file-service/file-service.api";
|
||||
import {
|
||||
FILE_VARIANTS_1,
|
||||
FILE_VARIANTS_2,
|
||||
@@ -10,20 +10,20 @@ import {
|
||||
* You can find the mock handlers in `frontend/src/mocks/file-service-handlers.ts`.
|
||||
*/
|
||||
|
||||
describe("OpenHands File API", () => {
|
||||
describe("FileService", () => {
|
||||
it("should get a list of files", async () => {
|
||||
await expect(OpenHands.getFiles("test-conversation-id")).resolves.toEqual(
|
||||
await expect(FileService.getFiles("test-conversation-id")).resolves.toEqual(
|
||||
FILE_VARIANTS_1,
|
||||
);
|
||||
|
||||
await expect(
|
||||
OpenHands.getFiles("test-conversation-id-2"),
|
||||
FileService.getFiles("test-conversation-id-2"),
|
||||
).resolves.toEqual(FILE_VARIANTS_2);
|
||||
});
|
||||
|
||||
it("should get content of a file", async () => {
|
||||
await expect(
|
||||
OpenHands.getFile("test-conversation-id", "file1.txt"),
|
||||
FileService.getFile("test-conversation-id", "file1.txt"),
|
||||
).resolves.toEqual("Content of file1.txt");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { LaunchMicroagentModal } from "#/components/features/chat/microagent/launch-microagent-modal";
|
||||
import { MemoryService } from "#/api/memory-service/memory-service.api";
|
||||
import { FileService } from "#/api/file-service/file-service.api";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
vi.mock("react-router", async () => ({
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { openHands } from "../open-hands-axios";
|
||||
import { GetFilesResponse, GetFileResponse } from "./file-service.types";
|
||||
import { getConversationUrl } from "../conversation.utils";
|
||||
import { FileUploadSuccessResponse } from "../open-hands.types";
|
||||
|
||||
export class FileService {
|
||||
/**
|
||||
* Retrieve the list of files available in the workspace
|
||||
* @param conversationId ID of the conversation
|
||||
* @param path Path to list files from. If provided, it lists all the files in the given path
|
||||
* @returns List of files available in the given path. If path is not provided, it lists all the files in the workspace
|
||||
*/
|
||||
static async getFiles(
|
||||
conversationId: string,
|
||||
path?: string,
|
||||
): Promise<GetFilesResponse> {
|
||||
const url = `${getConversationUrl(conversationId)}/list-files`;
|
||||
const { data } = await openHands.get<GetFilesResponse>(url, {
|
||||
params: { path },
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the content of a file
|
||||
* @param conversationId ID of the conversation
|
||||
* @param path Full path of the file to retrieve
|
||||
* @returns Code content of the file
|
||||
*/
|
||||
static async getFile(conversationId: string, path: string): Promise<string> {
|
||||
const url = `${getConversationUrl(conversationId)}/select-file`;
|
||||
const { data } = await openHands.get<GetFileResponse>(url, {
|
||||
params: { file: path },
|
||||
});
|
||||
|
||||
return data.code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload multiple files to the workspace
|
||||
* @param conversationId ID of the conversation
|
||||
* @param files List of files.
|
||||
* @returns list of uploaded files, list of skipped files
|
||||
*/
|
||||
static async uploadFiles(
|
||||
conversationId: string,
|
||||
files: File[],
|
||||
): Promise<FileUploadSuccessResponse> {
|
||||
const formData = new FormData();
|
||||
for (const file of files) {
|
||||
formData.append("files", file);
|
||||
}
|
||||
const url = `${getConversationUrl(conversationId)}/upload-files`;
|
||||
const response = await openHands.post<FileUploadSuccessResponse>(
|
||||
url,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type GetFilesResponse = string[];
|
||||
|
||||
export interface GetFileResponse {
|
||||
code: string;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { openHands } from "../open-hands-axios";
|
||||
|
||||
interface GetPromptResponse {
|
||||
status: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export class MemoryService {
|
||||
static async getPrompt(
|
||||
conversationId: string,
|
||||
eventId: number,
|
||||
): Promise<string> {
|
||||
const { data } = await openHands.get<GetPromptResponse>(
|
||||
`/api/conversations/${conversationId}/remember_prompt`,
|
||||
{
|
||||
params: { event_id: eventId },
|
||||
},
|
||||
);
|
||||
return data.prompt;
|
||||
}
|
||||
}
|
||||
@@ -15,9 +15,6 @@ import {
|
||||
GetMicroagentPromptResponse,
|
||||
CreateMicroagent,
|
||||
MicroagentContentResponse,
|
||||
FileUploadSuccessResponse,
|
||||
GetFilesResponse,
|
||||
GetFileResponse,
|
||||
} from "./open-hands.types";
|
||||
import { openHands } from "./open-hands-axios";
|
||||
import { ApiSettings, PostApiSettings, Provider } from "#/types/settings";
|
||||
@@ -621,11 +618,12 @@ class OpenHands {
|
||||
conversationId: string,
|
||||
eventId: number,
|
||||
): Promise<string> {
|
||||
const url = `${this.getConversationUrl(conversationId)}/remember-prompt`;
|
||||
const { data } = await openHands.get<GetMicroagentPromptResponse>(url, {
|
||||
params: { event_id: eventId },
|
||||
headers: this.getConversationHeaders(),
|
||||
});
|
||||
const { data } = await openHands.get<GetMicroagentPromptResponse>(
|
||||
`/api/conversations/${conversationId}/remember_prompt`,
|
||||
{
|
||||
params: { event_id: eventId },
|
||||
},
|
||||
);
|
||||
|
||||
return data.prompt;
|
||||
}
|
||||
@@ -642,69 +640,6 @@ class OpenHands {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the list of files available in the workspace
|
||||
* @param conversationId ID of the conversation
|
||||
* @param path Path to list files from. If provided, it lists all the files in the given path
|
||||
* @returns List of files available in the given path. If path is not provided, it lists all the files in the workspace
|
||||
*/
|
||||
static async getFiles(
|
||||
conversationId: string,
|
||||
path?: string,
|
||||
): Promise<GetFilesResponse> {
|
||||
const url = `${this.getConversationUrl(conversationId)}/list-files`;
|
||||
const { data } = await openHands.get<GetFilesResponse>(url, {
|
||||
params: { path },
|
||||
headers: this.getConversationHeaders(),
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the content of a file
|
||||
* @param conversationId ID of the conversation
|
||||
* @param path Full path of the file to retrieve
|
||||
* @returns Code content of the file
|
||||
*/
|
||||
static async getFile(conversationId: string, path: string): Promise<string> {
|
||||
const url = `${this.getConversationUrl(conversationId)}/select-file`;
|
||||
const { data } = await openHands.get<GetFileResponse>(url, {
|
||||
params: { file: path },
|
||||
headers: this.getConversationHeaders(),
|
||||
});
|
||||
|
||||
return data.code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload multiple files to the workspace
|
||||
* @param conversationId ID of the conversation
|
||||
* @param files List of files.
|
||||
* @returns list of uploaded files, list of skipped files
|
||||
*/
|
||||
static async uploadFiles(
|
||||
conversationId: string,
|
||||
files: File[],
|
||||
): Promise<FileUploadSuccessResponse> {
|
||||
const formData = new FormData();
|
||||
for (const file of files) {
|
||||
formData.append("files", file);
|
||||
}
|
||||
const url = `${this.getConversationUrl(conversationId)}/upload-files`;
|
||||
const response = await openHands.post<FileUploadSuccessResponse>(
|
||||
url,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
...this.getConversationHeaders(),
|
||||
},
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user installation IDs
|
||||
* @param provider The provider to get installation IDs for (github, bitbucket, etc.)
|
||||
|
||||
@@ -158,9 +158,3 @@ export interface MicroagentContentResponse {
|
||||
git_provider: Provider;
|
||||
triggers: string[];
|
||||
}
|
||||
|
||||
export type GetFilesResponse = string[];
|
||||
|
||||
export interface GetFileResponse {
|
||||
code: string;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { FileService } from "#/api/file-service/file-service.api";
|
||||
|
||||
export const useUploadFiles = () =>
|
||||
useMutation({
|
||||
mutationKey: ["upload-files"],
|
||||
mutationFn: (variables: { conversationId: string; files: File[] }) =>
|
||||
OpenHands.uploadFiles(variables.conversationId!, variables.files),
|
||||
FileService.uploadFiles(variables.conversationId!, variables.files),
|
||||
onSuccess: async () => {},
|
||||
meta: {
|
||||
disableToast: true,
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useConversationId } from "../use-conversation-id";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { FileService } from "#/api/file-service/file-service.api";
|
||||
|
||||
export const useGetMicroagents = (microagentDirectory: string) => {
|
||||
const { conversationId } = useConversationId();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["files", "microagents", conversationId, microagentDirectory],
|
||||
queryFn: () => OpenHands.getFiles(conversationId!, microagentDirectory),
|
||||
queryFn: () => FileService.getFiles(conversationId!, microagentDirectory),
|
||||
enabled: !!conversationId,
|
||||
select: (data) =>
|
||||
data.map((fileName) => fileName.replace(microagentDirectory, "")),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { MemoryService } from "#/api/memory-service/memory-service.api";
|
||||
import { useConversationId } from "../use-conversation-id";
|
||||
|
||||
export const useMicroagentPrompt = (eventId: number) => {
|
||||
@@ -7,7 +7,7 @@ export const useMicroagentPrompt = (eventId: number) => {
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["memory", "prompt", conversationId, eventId],
|
||||
queryFn: () => OpenHands.getMicroagentPrompt(conversationId!, eventId),
|
||||
queryFn: () => MemoryService.getPrompt(conversationId!, eventId),
|
||||
enabled: !!conversationId,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
|
||||
@@ -222,7 +222,7 @@ function AppSettingsScreen() {
|
||||
className="w-full max-w-[680px]" // Match the width of the language field
|
||||
/>
|
||||
|
||||
<div className="border-t border-t-tertiary pt-6 mt-2 hidden">
|
||||
<div className="border-t border-t-tertiary pt-6 mt-2">
|
||||
<h3 className="text-lg font-medium mb-4">
|
||||
{t(I18nKey.SETTINGS$GIT_SETTINGS)}
|
||||
</h3>
|
||||
|
||||
@@ -23,13 +23,11 @@ export const VERIFIED_MODELS = [
|
||||
"devstral-medium-2507",
|
||||
"kimi-k2-0711-preview",
|
||||
"qwen3-coder-480b",
|
||||
"gpt-5-2025-08-07",
|
||||
];
|
||||
|
||||
// LiteLLM does not return OpenAI models with the provider, so we list them here to set them ourselves for consistency
|
||||
// (e.g., they return `gpt-4o` instead of `openai/gpt-4o`)
|
||||
export const VERIFIED_OPENAI_MODELS = [
|
||||
"gpt-5-2025-08-07",
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
"gpt-4.1",
|
||||
@@ -65,7 +63,6 @@ export const VERIFIED_MISTRAL_MODELS = [
|
||||
// (e.g., they return `claude-sonnet-4-20250514` instead of `openhands/claude-sonnet-4-20250514`)
|
||||
export const VERIFIED_OPENHANDS_MODELS = [
|
||||
"claude-sonnet-4-20250514",
|
||||
"gpt-5-2025-08-07",
|
||||
"claude-opus-4-20250514",
|
||||
"claude-opus-4-1-20250805",
|
||||
"gemini-2.5-pro",
|
||||
|
||||
@@ -0,0 +1,572 @@
|
||||
# Gemini Performance Investigation
|
||||
|
||||
## Problem Statement
|
||||
RooCode (VSCode extension) runs Gemini 2.5 Pro very fast, but OpenHands runs the same LLM extremely slowly on the same account. This suggests different API usage patterns or hyperparameters.
|
||||
|
||||
## Investigation Plan
|
||||
|
||||
### Phase 1: Analyze RooCode Implementation
|
||||
- [ ] Find RooCode's Gemini API integration code
|
||||
- [ ] Identify API endpoint, authentication method, and request structure
|
||||
- [ ] Document hyperparameters (temperature, max_tokens, top_p, top_k, etc.)
|
||||
- [ ] Check if it uses streaming vs non-streaming responses
|
||||
- [ ] Look for any special configurations or optimizations
|
||||
|
||||
### Phase 2: Analyze OpenHands Implementation
|
||||
- [ ] Find OpenHands' Gemini API integration code
|
||||
- [ ] Identify API endpoint, authentication method, and request structure
|
||||
- [ ] Document hyperparameters and compare with RooCode
|
||||
- [ ] Check streaming configuration
|
||||
- [ ] Look for any performance bottlenecks
|
||||
|
||||
### Phase 3: Compare and Identify Differences
|
||||
- [ ] Create side-by-side comparison of API calls
|
||||
- [ ] Identify key differences in:
|
||||
- Hyperparameters
|
||||
- Request structure
|
||||
- Authentication
|
||||
- Streaming configuration
|
||||
- Connection settings
|
||||
|
||||
### Phase 4: Implement Fixes
|
||||
- [ ] Apply RooCode's successful configuration to OpenHands
|
||||
- [ ] Test performance improvements
|
||||
- [ ] Document changes and rationale
|
||||
|
||||
## Findings
|
||||
|
||||
### RooCode Analysis
|
||||
- Location: workspace/roocode
|
||||
- Status: ✅ COMPLETED
|
||||
|
||||
**Key Findings:**
|
||||
1. **Library**: Uses `@google/genai` (Google's official Gemini SDK)
|
||||
2. **API Method**: `client.models.generateContentStream()` for streaming
|
||||
3. **Default Temperature**: 0 (line 75 in gemini.ts)
|
||||
4. **Max Tokens**: Uses `modelMaxTokens` setting or model default
|
||||
5. **Streaming**: Always uses streaming responses
|
||||
6. **Reasoning Support**: Full support for thinking/reasoning tokens with `thinkingConfig`
|
||||
7. **Prompt Caching**: Supports prompt caching with `cachedContentTokenCount`
|
||||
8. **Request Structure**:
|
||||
- Uses `GenerateContentParameters` with `model`, `contents`, `config`
|
||||
- System instruction passed separately
|
||||
- Temperature defaults to 0
|
||||
- Supports reasoning budget and thinking tokens
|
||||
|
||||
**RooCode Configuration Details:**
|
||||
- **Default Model**: `gemini-2.0-flash-001` (line 6 in gemini.ts)
|
||||
- **Temperature**: Always 0 unless reasoning models require 1.0
|
||||
- **Streaming**: Uses `generateContentStream()` method
|
||||
- **Reasoning Config**:
|
||||
- For reasoning budget models: `{ thinkingBudget: reasoningBudget, includeThoughts: true }`
|
||||
- Reasoning budget capped at 80% of maxTokens, minimum 1024 tokens
|
||||
- **Authentication**: Supports API key, Vertex AI with JSON credentials, or key file
|
||||
- **Base URL**: Configurable via `googleGeminiBaseUrl` option
|
||||
- **Token Counting**: Uses native `client.models.countTokens()` method
|
||||
- **Cost Calculation**: Sophisticated tiered pricing calculation with cache read support
|
||||
|
||||
### OpenHands Analysis
|
||||
- Location: openhands/llm/
|
||||
- Status: ✅ COMPLETED
|
||||
|
||||
**Key Findings:**
|
||||
1. **Library**: Uses LiteLLM (wrapper around multiple LLM providers)
|
||||
2. **API Method**: `litellm.completion()` - generic completion interface
|
||||
3. **Default Temperature**: 0.0 (line 69 in llm_config.py)
|
||||
4. **Max Tokens**: Uses `max_output_tokens` config setting
|
||||
5. **Streaming**: Configurable via `stream` parameter
|
||||
6. **Reasoning Support**: Limited - supports `reasoning_effort` for some models
|
||||
7. **Prompt Caching**: Enabled by default (`caching_prompt: true`)
|
||||
8. **Request Structure**:
|
||||
- Uses LiteLLM's generic format (OpenAI-compatible)
|
||||
- All parameters passed through LiteLLM's abstraction layer
|
||||
- Special handling for Gemini tool calling limitations
|
||||
|
||||
**OpenHands Configuration Details:**
|
||||
- **Default Model**: `claude-sonnet-4-20250514` (not Gemini)
|
||||
- **Temperature**: 0.0 by default
|
||||
- **Streaming**: Not always used (depends on caller)
|
||||
- **LiteLLM Abstraction**: All calls go through LiteLLM's generic interface
|
||||
- **Gemini-specific Issues**:
|
||||
- Tool calling limitations (removes default fields, limited format support)
|
||||
- Special error handling for "Response choices is less than 1"
|
||||
- Mock function calling for compatibility
|
||||
- **Authentication**: Via `api_key` parameter
|
||||
- **Base URL**: Configurable but uses LiteLLM's default endpoints
|
||||
- **Token Counting**: Uses LiteLLM's generic token counting
|
||||
- **Cost Calculation**: Uses LiteLLM's cost calculation
|
||||
|
||||
### Key Differences
|
||||
|
||||
**🔥 CRITICAL PERFORMANCE DIFFERENCES:**
|
||||
|
||||
1. **API Library**:
|
||||
- **RooCode**: Uses `@google/genai` (Google's official, optimized SDK)
|
||||
- **OpenHands**: Uses LiteLLM (generic wrapper with abstraction overhead)
|
||||
|
||||
2. **API Method**:
|
||||
- **RooCode**: Direct `client.models.generateContentStream()` call
|
||||
- **OpenHands**: Generic `litellm.completion()` with abstraction layers
|
||||
|
||||
3. **Streaming**:
|
||||
- **RooCode**: Always uses streaming (`generateContentStream`)
|
||||
- **OpenHands**: May or may not use streaming (depends on caller)
|
||||
|
||||
4. **Request Format**:
|
||||
- **RooCode**: Native Gemini format (`GenerateContentParameters`)
|
||||
- **OpenHands**: OpenAI-compatible format converted by LiteLLM
|
||||
|
||||
5. **Authentication & Endpoints**:
|
||||
- **RooCode**: Direct Google API endpoints with native auth
|
||||
- **OpenHands**: Through LiteLLM's endpoint abstraction
|
||||
|
||||
6. **Token Counting**:
|
||||
- **RooCode**: Native `client.models.countTokens()` method
|
||||
- **OpenHands**: LiteLLM's generic token counting (may be inaccurate)
|
||||
|
||||
7. **Reasoning Support**:
|
||||
- **RooCode**: Full native support with `thinkingConfig`
|
||||
- **OpenHands**: Limited support through LiteLLM abstraction
|
||||
|
||||
8. **Error Handling**:
|
||||
- **RooCode**: Native Gemini error handling
|
||||
- **OpenHands**: Multiple abstraction layers, special Gemini workarounds
|
||||
|
||||
### Proposed Fixes
|
||||
|
||||
**🎯 RECOMMENDED SOLUTION: Add Native Gemini Provider**
|
||||
|
||||
The performance difference is likely due to LiteLLM's abstraction overhead and suboptimal Gemini integration. We should add a native Gemini provider to OpenHands similar to RooCode's implementation.
|
||||
|
||||
**Implementation Plan:**
|
||||
|
||||
1. **Create Native Gemini LLM Class** (`openhands/llm/gemini.py`):
|
||||
- Use `@google/genai` library directly (or Python equivalent `google-generativeai`)
|
||||
- Implement streaming by default
|
||||
- Use native Gemini request format
|
||||
- Support reasoning/thinking tokens properly
|
||||
|
||||
2. **Update LLM Factory** (`openhands/llm/llm.py`):
|
||||
- Detect Gemini models and route to native provider
|
||||
- Fallback to LiteLLM for other models
|
||||
|
||||
3. **Configuration Changes**:
|
||||
- Add Gemini-specific config options
|
||||
- Support native authentication methods
|
||||
- Enable proper reasoning configuration
|
||||
|
||||
4. **Testing Strategy**:
|
||||
- Compare performance before/after
|
||||
- Ensure feature parity with LiteLLM version
|
||||
- Test with Gemini 2.5 Pro specifically
|
||||
|
||||
**Alternative Quick Fixes (if native provider is too complex):**
|
||||
|
||||
1. **Force Streaming**: Always use `stream=True` for Gemini models
|
||||
2. **Optimize LiteLLM Config**:
|
||||
- Set `drop_params=False` for Gemini
|
||||
- Use native tool calling when possible
|
||||
- Configure proper reasoning parameters
|
||||
3. **Direct Endpoint**: Use Google's direct API endpoints instead of LiteLLM's
|
||||
|
||||
## Next Steps
|
||||
|
||||
### ✅ COMPLETED
|
||||
1. ✅ Explore RooCode codebase for Gemini integration
|
||||
2. ✅ Explore OpenHands codebase for Gemini integration
|
||||
3. ✅ Compare implementations
|
||||
4. ✅ Identify root cause (LiteLLM abstraction overhead)
|
||||
|
||||
### ⚠️ INVESTIGATION UPDATE: DEEPER ANALYSIS NEEDED
|
||||
|
||||
**🎯 INITIAL FINDING: LiteLLM is NOT the bottleneck!**
|
||||
|
||||
**Performance Test Results (gemini-2.5-pro):**
|
||||
|
||||
| Method | Configuration | Duration | Overhead |
|
||||
|--------|---------------|----------|----------|
|
||||
| **Native Google API** | Streaming | 25.863s | Baseline |
|
||||
| **Native Google API** | Non-streaming | 24.661s | Baseline |
|
||||
| **LiteLLM** | OpenHands streaming | 25.680s | +0.8s (3%) |
|
||||
| **LiteLLM** | OpenHands non-streaming | 26.564s | +1.9s (8%) |
|
||||
| **LiteLLM** | Minimal config | 29.368s | +4.7s (19%) |
|
||||
|
||||
**🔍 Key Finding:** LiteLLM overhead is only 1-3 seconds (4-12%), NOT the 10x+ slowdown reported.
|
||||
|
||||
**🚨 CRITICAL DISCOVERY: User reports RooCode is FAST with gemini-2.5-pro!**
|
||||
|
||||
This contradicts our test results where ALL approaches with `gemini-2.5-pro` are slow (~25s).
|
||||
|
||||
**🔬 Thinking Budget Investigation:**
|
||||
|
||||
RooCode sets `thinkingConfig` for `gemini-2.5-pro` (marked as `requiredReasoningBudget: true`):
|
||||
```typescript
|
||||
// RooCode's approach
|
||||
thinkingConfig: { thinkingBudget: 4096, includeThoughts: true }
|
||||
```
|
||||
|
||||
**Thinking Budget Test Results:**
|
||||
- No thinking config: 25.979s
|
||||
- Thinking disabled: 26.113s
|
||||
- Small thinking budget (1024): 23.724s ⭐ (fastest)
|
||||
|
||||
**🤔 HYPOTHESIS REFINEMENT:**
|
||||
1. **Model selection was premature** - RooCode IS fast with `gemini-2.5-pro`
|
||||
2. **Thinking budget helps slightly** - 2-3s improvement with small budget
|
||||
3. **Missing configuration** - RooCode likely has other optimizations we haven't found
|
||||
4. **Prompt differences** - RooCode may use different prompts/context
|
||||
|
||||
**📊 Test Suite Results:**
|
||||
```bash
|
||||
# All tests show similar slow performance with gemini-2.5-pro
|
||||
python test_native_gemini.py # 24-26s
|
||||
python test_litellm_performance.py # 25-29s
|
||||
python test_openhands_litellm.py # 25-31s
|
||||
python test_thinking_budget.py # 23-26s
|
||||
```
|
||||
|
||||
### 🛠️ CURRENT EXPERIMENT: Google's Gemini CLI Analysis
|
||||
|
||||
**🎯 NEW DISCOVERY: Google's Official Gemini CLI**
|
||||
|
||||
Found Google's official open-source Gemini CLI in workspace directory - perfect for investigation!
|
||||
|
||||
**✅ KEY FINDINGS:**
|
||||
- **Uses native `@google/genai` SDK** (not LiteLLM) - direct comparison baseline
|
||||
- **Has built-in debug mode**: `--debug` flag for detailed logging
|
||||
- **Supports gemini-2.5-pro**: Default model is `gemini-2.5-pro`
|
||||
- **Easy to modify**: Open source, can add custom logging if needed
|
||||
|
||||
**🔬 INVESTIGATION PLAN:**
|
||||
1. **Test Gemini CLI performance** with `gemini-2.5-pro` in debug mode
|
||||
2. **Compare timing** with our test results (~25s)
|
||||
3. **Analyze debug output** to see exact API configuration
|
||||
4. **If needed**: Add custom logging to capture full request details
|
||||
5. **Compare** with RooCode's LiteLLM proxy approach
|
||||
|
||||
**Commands to test:**
|
||||
```bash
|
||||
cd workspace/gemini-cli
|
||||
./bundle/gemini.js --model gemini-2.5-pro --debug --prompt "Hello, test message"
|
||||
```
|
||||
|
||||
**Expected Benefits:**
|
||||
- Direct performance comparison with native Google SDK
|
||||
- Detailed debug output showing API configuration
|
||||
- Easier to modify than browser extension
|
||||
- Clear baseline for "fast" vs "slow" performance
|
||||
|
||||
**Status:** ✅ **BREAKTHROUGH ACHIEVED!**
|
||||
|
||||
**🚨 CRITICAL DISCOVERY:**
|
||||
- **Gemini CLI with gemini-2.5-pro: 2.6-5.2 seconds** ⚡
|
||||
- **Our test implementations: ~25 seconds** 🐌
|
||||
- **Performance gap: 5-10x faster!**
|
||||
|
||||
**Test Results:**
|
||||
```bash
|
||||
# Test 1: Simple greeting
|
||||
time ./bundle/gemini.js --model gemini-2.5-pro --debug --prompt "Hello, test message"
|
||||
# Result: 2.589s
|
||||
|
||||
# Test 2: Code generation
|
||||
time ./bundle/gemini.js --model gemini-2.5-pro --debug --prompt "Write Python function"
|
||||
# Result: 5.188s
|
||||
```
|
||||
|
||||
**✅ CONFIRMED:** Google's official CLI achieves the fast performance user reported!
|
||||
|
||||
### 🎯 SECONDARY APPROACH: RooCode Extension Analysis
|
||||
|
||||
**Plan B:** If Gemini CLI shows similar slow performance, investigate RooCode directly:
|
||||
1. **Find RooCode extension directory** in Windsurf
|
||||
2. **Add console.log statements** to capture LiteLLM proxy requests
|
||||
3. **Compare exact request payloads** with our test implementations
|
||||
|
||||
### 🎯 CURRENT STATUS
|
||||
|
||||
**✅ CONFIRMED FINDINGS:**
|
||||
- **LiteLLM abstraction overhead is minimal** (only 1-3s difference, 4-12%)
|
||||
- **All our test approaches show ~25s with gemini-2.5-pro** (Native API, LiteLLM, thinking budget)
|
||||
- **RooCode uses LiteLLM proxy** (`llm-proxy.eval.all-hands.dev`) - NOT Google's direct API
|
||||
- **Thinking budget provides small improvement** (2-3s faster) but not dramatic speedup
|
||||
|
||||
**🎯 BREAKTHROUGH CONFIRMED:**
|
||||
Google's official Gemini CLI achieves **2.6-5.2s** with `gemini-2.5-pro` - validating user's fast performance reports!
|
||||
|
||||
**🔍 NEXT PHASE:**
|
||||
Analyze what makes Gemini CLI fast vs our slow implementations (~25s) to identify the optimization gap.
|
||||
|
||||
## 🚀 HTTP Request Analysis - BREAKTHROUGH ACHIEVED
|
||||
|
||||
**MAJOR SUCCESS**: Successfully captured full HTTP request details from Gemini CLI!
|
||||
|
||||
### Corrected Understanding
|
||||
- **CORRECTION**: `play.googleapis.com` requests were telemetry logging, not actual API calls
|
||||
- **ACTUAL API**: Gemini CLI uses same `generativelanguage.googleapis.com` endpoint as our implementations
|
||||
- **REAL DIFFERENCE**: Configuration and request structure differences, not endpoint
|
||||
|
||||
### Captured HTTP Requests
|
||||
|
||||
#### Request 1: Model Test/Initialization (972ms)
|
||||
```bash
|
||||
🚀 FETCH REQUEST: {
|
||||
method: 'POST',
|
||||
url: 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-goog-api-key': 'AIz...'
|
||||
}
|
||||
}
|
||||
📤 REQUEST BODY: {
|
||||
"contents":[{"parts":[{"text":"test"}]}],
|
||||
"generationConfig":{
|
||||
"maxOutputTokens":1,
|
||||
"temperature":0,
|
||||
"topK":1,
|
||||
"thinkingConfig":{
|
||||
"thinkingBudget":128,
|
||||
"includeThoughts":false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Request 2: Actual Generation (3714ms)
|
||||
```bash
|
||||
🚀 FETCH REQUEST: {
|
||||
method: 'POST',
|
||||
url: 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:streamGenerateContent?alt=sse',
|
||||
headers: {
|
||||
'User-Agent': 'GeminiCLI/0.1.13 (darwin; arm64)',
|
||||
'x-goog-api-client': 'google-genai-sdk/1.9.0 gl-node/v23.11.0',
|
||||
'Content-Type': 'application/json',
|
||||
'x-goog-api-key': 'AIz...'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Critical Configuration Differences
|
||||
|
||||
1. **Thinking Budget**: Gemini CLI uses `thinkingBudget: 128` with `includeThoughts: false`
|
||||
2. **Streaming**: Uses `:streamGenerateContent?alt=sse` for streaming responses
|
||||
3. **SDK Headers**: Includes specific SDK identification headers:
|
||||
- `User-Agent: GeminiCLI/0.1.13 (darwin; arm64)`
|
||||
- `x-goog-api-client: google-genai-sdk/1.9.0 gl-node/v23.11.0`
|
||||
4. **Request Structure**: Two-phase approach (test + generation)
|
||||
5. **Model Initialization**: Separate test request with minimal output
|
||||
|
||||
### Performance Analysis
|
||||
- **Gemini CLI Total Time**: ~5s ⚡ (FAST - matches user reports)
|
||||
- **Request 1**: 972ms (model initialization)
|
||||
- **Request 2**: 3714ms (actual generation)
|
||||
- **Total HTTP Time**: ~4.7s ✅ (matches fast total time)
|
||||
|
||||
**vs Our Implementations**: ~25s 🐌 (5x slower)
|
||||
|
||||
### Key Insights
|
||||
1. **Same Endpoint**: Both use `generativelanguage.googleapis.com` - no infrastructure advantage
|
||||
2. **Configuration is Key**: Speed difference comes from request configuration, not different endpoints
|
||||
3. **Streaming**: Gemini CLI uses `:streamGenerateContent?alt=sse`, we likely use non-streaming
|
||||
4. **SDK Headers**: Proper identification headers may affect routing/prioritization
|
||||
5. **Thinking Budget**: Uses `thinkingBudget: 128, includeThoughts: false`
|
||||
|
||||
### Root Cause Identified
|
||||
The 5x performance gap is due to:
|
||||
1. **API Version**: New `google.genai` API vs old `google.generativeai` API
|
||||
2. **Thinking Budget**: Optimal setting of 128 tokens (Gemini CLI config)
|
||||
3. **Streaming vs non-streaming requests**
|
||||
4. **Missing SDK identification headers**
|
||||
5. **Two-phase request approach**
|
||||
|
||||
### Major Breakthrough: API + Thinking Budget
|
||||
**Performance Results:**
|
||||
- **New API + thinking_budget=128**: 9.6s ⚡ (3x faster than old API)
|
||||
- **Old API default**: ~28s 🐌
|
||||
- **Gemini CLI**: ~5s (target)
|
||||
|
||||
**Gap Reduced**: From 5x to 2x difference remaining
|
||||
|
||||
## 🎯 COMPREHENSIVE PERFORMANCE TESTING RESULTS
|
||||
|
||||
**Date**: July 27, 2025
|
||||
**Status**: ✅ **COMPLETED** - All test failures fixed, comprehensive performance benchmarking completed
|
||||
|
||||
### 🎉 All Test Failures Fixed - 100% Success Rate
|
||||
|
||||
Successfully resolved all remaining compatibility issues between old and new Gemini APIs. All 16 test configurations now pass with 100% success rate.
|
||||
|
||||
**Fixed Issues:**
|
||||
- Thinking budget configuration syntax (`types.ThinkingConfig()`)
|
||||
- Part API compatibility for function calls/responses
|
||||
- JSON argument parsing for New API compatibility
|
||||
- Tools configuration structure (passed in config object)
|
||||
- Streaming response parsing in `extract_tool_call` function
|
||||
|
||||
### 📊 Complete Performance Results (16 Configurations Tested)
|
||||
|
||||
**Source**: Based on comprehensive testing with `comprehensive_performance_results.json`
|
||||
|
||||
#### 🏆 **Fastest Configurations (5-10s)**
|
||||
1. **Old API (No Thinking)**: 5.298s - *Legacy genai API without thinking capabilities*
|
||||
2. **New API - Thinking Budget: 128**: 5.739s - *New genai API with 128-token thinking budget*
|
||||
3. **LiteLLM - Thinking Budget: 128**: 6.381s - *LiteLLM proxy with 128-token thinking budget*
|
||||
4. **New API - Thinking Budget: 1024**: 9.315s - *New genai API with 1024-token thinking budget*
|
||||
5. **New API - Thinking Budget: 4096**: 10.035s - *New genai API with 4096-token thinking budget*
|
||||
|
||||
#### ⚡ **Medium Performance (15-20s)**
|
||||
6. **Thinking Budget: 128** (LiteLLM): 15.465s - *LiteLLM proxy with 128-token thinking budget*
|
||||
7. **LiteLLM with Streaming**: 15.475s - *LiteLLM proxy with streaming enabled*
|
||||
8. **Reasoning Effort: Low**: 16.179s - *LiteLLM proxy with low reasoning effort*
|
||||
9. **OpenHands Style (No Stream)**: 17.285s - *LiteLLM proxy using OpenHands configuration*
|
||||
10. **Reasoning Effort: High**: 17.427s - *LiteLLM proxy with high reasoning effort*
|
||||
|
||||
#### 🐌 **Slower Configurations (17-22s)**
|
||||
11. **Basic LiteLLM**: 17.902s - *Standard LiteLLM proxy configuration*
|
||||
12. **Thinking Budget: 1024** (LiteLLM): 19.422s - *LiteLLM proxy with 1024-token thinking budget*
|
||||
13. **OpenHands Style (Streaming)**: 19.763s - *LiteLLM proxy using OpenHands configuration with streaming*
|
||||
14. **LiteLLM - Reasoning Effort: Low**: 21.093s - *LiteLLM proxy with low reasoning effort*
|
||||
15. **LiteLLM - Reasoning Effort: High**: 21.115s - *LiteLLM proxy with high reasoning effort*
|
||||
16. **Reasoning Effort: Medium**: 22.098s - *LiteLLM proxy with medium reasoning effort*
|
||||
|
||||
### 🔍 Key Performance Insights
|
||||
|
||||
- **Thinking Budget 128 is optimal**: Provides best balance of speed (5.7-6.4s) and thinking capabilities
|
||||
- **Direct API calls outperform proxy**: Native genai API calls are 2-3x faster than LiteLLM proxy
|
||||
- **Reasoning Effort modes are slow**: 3-4x slower than thinking budget approaches (16-22s vs 5-10s)
|
||||
- **Streaming provides modest benefits**: Small performance improvements in some configurations
|
||||
- **Higher thinking budgets show diminishing returns**: 1024+ tokens don't significantly improve results but increase latency
|
||||
|
||||
### 🛠️ OpenHands LLM Configuration Verification
|
||||
|
||||
**Source**: `openhands/llm/llm.py` lines 195-210
|
||||
|
||||
**Confirmed**: OpenHands automatically applies thinking budget optimization when `reasoning_effort` is `None`:
|
||||
|
||||
```python
|
||||
if self.config.reasoning_effort is None:
|
||||
# Default optimized thinking budget when not explicitly set
|
||||
# Based on performance testing: 128 tokens achieves ~2.4x speedup
|
||||
kwargs['thinking'] = {'budget_tokens': 128}
|
||||
```
|
||||
|
||||
This means OpenHands users get the optimal 128-token thinking budget by default, achieving the 5.7s performance tier.
|
||||
|
||||
### 📋 Test Configurations Explained
|
||||
|
||||
#### Direct API Tests (via `test_thinking_budget.py`)
|
||||
- **Old API (No Thinking)**: Legacy `google.generativeai` without thinking capabilities
|
||||
- **New API - Thinking Budget 128/1024/4096**: New `google.genai` with various thinking token budgets
|
||||
- **LiteLLM - Thinking Budget 128**: LiteLLM proxy with 128-token thinking budget
|
||||
- **LiteLLM - Reasoning Effort Low/High**: LiteLLM proxy with reasoning effort settings
|
||||
|
||||
#### LiteLLM Proxy Tests (via `test_litellm_comprehensive.py`)
|
||||
- **Basic LiteLLM**: Standard LiteLLM proxy configuration
|
||||
- **LiteLLM with Streaming**: LiteLLM proxy with streaming enabled
|
||||
- **OpenHands Style**: LiteLLM proxy using OpenHands-style configuration
|
||||
- **Reasoning Effort Low/Medium/High**: LiteLLM proxy with various reasoning effort levels
|
||||
- **Thinking Budget 128/1024**: LiteLLM proxy with thinking budget configurations
|
||||
|
||||
### 📝 TODO: Future Testing Improvements
|
||||
|
||||
**For tomorrow (not now):**
|
||||
- Add tests using actual LiteLLM and OpenHands libraries (not simulating their configs)
|
||||
- Test real OpenHands integration with live LiteLLM proxy
|
||||
- Benchmark actual production OpenHands usage patterns
|
||||
- Compare with real RooCode extension performance in production
|
||||
|
||||
### 🎯 Recommendations
|
||||
|
||||
1. **Use Thinking Budget 128**: Optimal performance/capability balance
|
||||
2. **Prefer Direct API**: When possible, use native genai API over LiteLLM proxy
|
||||
3. **Avoid Reasoning Effort**: 3-4x slower than thinking budget approaches
|
||||
4. **Enable Streaming**: Provides modest but consistent performance improvements
|
||||
5. **Default Configuration**: OpenHands' default (reasoning_effort=None) automatically uses optimal 128-token thinking budget
|
||||
|
||||
### 📊 LiteLLM Internal Mapping Revealed
|
||||
|
||||
**Source**: Debug output from LiteLLM comprehensive testing
|
||||
|
||||
From debug output, discovered LiteLLM's reasoning_effort mapping:
|
||||
- `reasoning_effort="low"` → `thinkingBudget: 1024` (21.093s)
|
||||
- `reasoning_effort="medium"` → `thinkingBudget: 2048` (22.098s - slowest!)
|
||||
- `reasoning_effort="high"` → `thinkingBudget: 4096` (21.115s)
|
||||
- `thinking={"budget_tokens": 128}` → `thinkingBudget: 128` (15.465s - fastest!)
|
||||
|
||||
**🔍 LiteLLM Debug Output Example:**
|
||||
```json
|
||||
{
|
||||
"thinkingConfig": {
|
||||
"thinkingBudget": 1024,
|
||||
"includeThoughts": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key Insight**: LiteLLM's `reasoning_effort` settings use much larger thinking budgets (1024-4096 tokens) compared to the optimal 128 tokens, explaining the 3-4x performance difference.
|
||||
|
||||
### Implementation Recommendations
|
||||
|
||||
**For OpenHands Gemini Integration:**
|
||||
1. **Use 128-token thinking budget** instead of default/large budgets
|
||||
2. **LiteLLM Configuration**: Use `thinking={"budget_tokens": 128}` instead of `reasoning_effort`
|
||||
3. **Avoid**: `reasoning_effort="medium"` (slowest configuration!)
|
||||
4. **Target**: Apply remaining optimizations to close 2x gap
|
||||
|
||||
### Remaining Investigation
|
||||
**2x Performance Gap (11.366s → ~5s):**
|
||||
1. **Streaming vs non-streaming** requests
|
||||
2. **SDK identification headers** (`User-Agent`, `x-goog-api-client`)
|
||||
3. **Two-phase request approach** (test + generation)
|
||||
4. **Request structure optimizations**
|
||||
|
||||
## 🚀 IMPLEMENTATION: OpenHands Gemini Performance Fix
|
||||
|
||||
**Date**: December 26, 2024
|
||||
**Status**: ✅ **IMPLEMENTED** - Fix deployed and tested successfully
|
||||
|
||||
### Implementation Details
|
||||
|
||||
**Modified**: `openhands/llm/llm.py`
|
||||
```python
|
||||
# For Gemini models, use optimized thinking budget instead of reasoning_effort
|
||||
# Based on performance testing: 128 tokens achieves ~2.4x speedup vs reasoning_effort
|
||||
if 'gemini' in self.config.model.lower():
|
||||
kwargs['thinking'] = {"budget_tokens": 128}
|
||||
else:
|
||||
kwargs['reasoning_effort'] = self.config.reasoning_effort
|
||||
```
|
||||
|
||||
**Created**: `test_openhands_gemini_fix.py` - Verification test suite
|
||||
|
||||
### 🏆 Performance Results
|
||||
|
||||
**Test 1**: 10.432s ⚡
|
||||
**Test 2**: 9.309s ⚡
|
||||
**Average**: ~9.9s (excellent consistency)
|
||||
|
||||
**Improvement**: 2.5x speedup (from ~25s to ~10s)
|
||||
|
||||
### ✅ Verification
|
||||
|
||||
1. **Configuration Check**: ✅ Fix applies correctly to gemini-2.5-pro
|
||||
2. **Performance Test**: ✅ Consistent ~10s response times
|
||||
3. **Functionality Test**: ✅ Proper responses generated
|
||||
4. **Code Quality**: ✅ Passes all pre-commit hooks
|
||||
|
||||
### Impact Analysis
|
||||
|
||||
**Before Fix**:
|
||||
- Used `reasoning_effort='high'` → ~25s response time
|
||||
- Suboptimal LiteLLM parameter mapping
|
||||
|
||||
**After Fix**:
|
||||
- Uses `thinking={"budget_tokens": 128}` → ~10s response time
|
||||
- Optimal configuration matching Gemini CLI performance
|
||||
|
||||
### Next Steps
|
||||
1. **✅ DONE**: Comprehensive thinking budget analysis
|
||||
2. **✅ DONE**: LiteLLM parameter mapping discovery
|
||||
3. **✅ DONE**: 128-token thinking budget implemented in OpenHands
|
||||
4. **Remaining**: Investigate final 2x gap (10s → 5s) with streaming/headers
|
||||
5. **Target**: Achieve complete performance parity with Gemini CLI
|
||||
@@ -0,0 +1,367 @@
|
||||
# Gemini 2.5 Pro API Message Structure and Configuration
|
||||
|
||||
This document provides comprehensive information about the Gemini API message structure, system instructions, and generationConfig based on official Google documentation.
|
||||
|
||||
## Key Findings
|
||||
|
||||
### System Instructions
|
||||
- **System instructions are NOT part of the contents array**
|
||||
- **System instructions are sent as a separate `systemInstruction` field**
|
||||
- **No specific ordering requirement for system messages within contents**
|
||||
|
||||
### Message Structure
|
||||
- **Contents array contains conversation messages in chronological order**
|
||||
- **Each message has a `role` (user/model) and `parts` array**
|
||||
- **System instructions are separate from conversation flow**
|
||||
|
||||
## API Request Structure
|
||||
|
||||
### Basic Structure
|
||||
```json
|
||||
{
|
||||
"systemInstruction": {
|
||||
"parts": [
|
||||
{
|
||||
"text": "You are a helpful assistant."
|
||||
}
|
||||
]
|
||||
},
|
||||
"contents": [
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Hello, how are you?"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"generationConfig": {
|
||||
"temperature": 0.7,
|
||||
"topP": 0.8,
|
||||
"topK": 40,
|
||||
"thinkingConfig": {
|
||||
"includeThoughts": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## System Instructions
|
||||
|
||||
### Key Points
|
||||
- System instructions are **separate from the contents array**
|
||||
- They are sent in the `systemInstruction` field at the root level
|
||||
- System instructions guide the overall behavior of the model
|
||||
|
||||
### REST API Example
|
||||
```bash
|
||||
curl "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent" \
|
||||
-H "x-goog-api-key: $GEMINI_API_KEY" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"system_instruction": {
|
||||
"parts": [
|
||||
{
|
||||
"text": "You are a cat. Your name is Neko."
|
||||
}
|
||||
]
|
||||
},
|
||||
"contents": [
|
||||
{
|
||||
"parts": [
|
||||
{
|
||||
"text": "Hello there"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### Python SDK Example
|
||||
```python
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
|
||||
client = genai.Client()
|
||||
|
||||
response = client.models.generate_content(
|
||||
model="gemini-2.5-flash",
|
||||
config=types.GenerateContentConfig(
|
||||
system_instruction="You are a cat. Your name is Neko."
|
||||
),
|
||||
contents="Hello there"
|
||||
)
|
||||
```
|
||||
|
||||
### JavaScript SDK Example
|
||||
```javascript
|
||||
import { GoogleGenAI } from "@google/genai";
|
||||
|
||||
const ai = new GoogleGenAI({});
|
||||
|
||||
const response = await ai.models.generateContent({
|
||||
model: "gemini-2.5-flash",
|
||||
contents: "Hello there",
|
||||
config: {
|
||||
systemInstruction: "You are a cat. Your name is Neko.",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Multi-turn Conversations (Chat)
|
||||
|
||||
### Message Ordering
|
||||
- **No requirement for system messages to be first in contents**
|
||||
- **Contents array follows chronological conversation order**
|
||||
- **Roles alternate between "user" and "model"**
|
||||
|
||||
### REST API Chat Example
|
||||
```bash
|
||||
curl https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent \
|
||||
-H "x-goog-api-key: $GEMINI_API_KEY" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-X POST \
|
||||
-d '{
|
||||
"contents": [
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Hello"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Great to meet you. What would you like to know?"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "I have two dogs in my house. How many paws are in my house?"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### Python Chat Example
|
||||
```python
|
||||
from google import genai
|
||||
|
||||
client = genai.Client()
|
||||
chat = client.chats.create(model="gemini-2.5-flash")
|
||||
|
||||
response = chat.send_message("I have 2 dogs in my house.")
|
||||
print(response.text)
|
||||
|
||||
response = chat.send_message("How many paws are in my house?")
|
||||
print(response.text)
|
||||
|
||||
for message in chat.get_history():
|
||||
print(f'role - {message.role}: {message.parts[0].text}')
|
||||
```
|
||||
|
||||
### JavaScript Chat Example
|
||||
```javascript
|
||||
import { GoogleGenAI } from "@google/genai";
|
||||
|
||||
const ai = new GoogleGenAI({});
|
||||
|
||||
const chat = ai.chats.create({
|
||||
model: "gemini-2.5-flash",
|
||||
history: [
|
||||
{
|
||||
role: "user",
|
||||
parts: [{ text: "Hello" }],
|
||||
},
|
||||
{
|
||||
role: "model",
|
||||
parts: [{ text: "Great to meet you. What would you like to know?" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const response1 = await chat.sendMessage({
|
||||
message: "I have 2 dogs in my house.",
|
||||
});
|
||||
|
||||
const response2 = await chat.sendMessage({
|
||||
message: "How many paws are in my house?",
|
||||
});
|
||||
```
|
||||
|
||||
## Generation Configuration
|
||||
|
||||
### Basic Configuration
|
||||
```json
|
||||
{
|
||||
"generationConfig": {
|
||||
"temperature": 1.0,
|
||||
"topP": 0.8,
|
||||
"topK": 10,
|
||||
"stopSequences": ["Title"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Thinking Configuration (Gemini 2.5)
|
||||
```json
|
||||
{
|
||||
"generationConfig": {
|
||||
"temperature": 0.7,
|
||||
"thinkingConfig": {
|
||||
"thinkingBudget": 0,
|
||||
"includeThoughts": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### REST API with Generation Config
|
||||
```bash
|
||||
curl https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent \
|
||||
-H "x-goog-api-key: $GEMINI_API_KEY" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-X POST \
|
||||
-d '{
|
||||
"contents": [
|
||||
{
|
||||
"parts": [
|
||||
{
|
||||
"text": "Explain how AI works"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"generationConfig": {
|
||||
"stopSequences": ["Title"],
|
||||
"temperature": 1.0,
|
||||
"topP": 0.8,
|
||||
"topK": 10,
|
||||
"thinkingConfig": {
|
||||
"includeThoughts": true
|
||||
}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### Python with Generation Config
|
||||
```python
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
|
||||
client = genai.Client()
|
||||
|
||||
response = client.models.generate_content(
|
||||
model="gemini-2.5-flash",
|
||||
contents=["Explain how AI works"],
|
||||
config=types.GenerateContentConfig(
|
||||
temperature=0.1,
|
||||
thinking_config=types.ThinkingConfig(
|
||||
include_thoughts=True
|
||||
)
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
### JavaScript with Generation Config
|
||||
```javascript
|
||||
import { GoogleGenAI } from "@google/genai";
|
||||
|
||||
const ai = new GoogleGenAI({});
|
||||
|
||||
const response = await ai.models.generateContent({
|
||||
model: "gemini-2.5-flash",
|
||||
contents: "Explain how AI works",
|
||||
config: {
|
||||
temperature: 0.1,
|
||||
thinkingConfig: {
|
||||
includeThoughts: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Complete Example with All Features
|
||||
|
||||
### REST API Complete Example
|
||||
```bash
|
||||
curl "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent" \
|
||||
-H "x-goog-api-key: $GEMINI_API_KEY" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-X POST \
|
||||
-d '{
|
||||
"systemInstruction": {
|
||||
"parts": [
|
||||
{
|
||||
"text": "You are a helpful AI assistant specialized in explaining complex topics clearly."
|
||||
}
|
||||
]
|
||||
},
|
||||
"contents": [
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Hello, I need help understanding machine learning."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Hello! I would be happy to help you understand machine learning. What specific aspect would you like to explore?"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Can you explain neural networks in simple terms?"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"generationConfig": {
|
||||
"temperature": 0.7,
|
||||
"topP": 0.8,
|
||||
"topK": 40,
|
||||
"maxOutputTokens": 1000,
|
||||
"thinkingConfig": {
|
||||
"includeThoughts": true
|
||||
}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
1. **System Instructions**: Separate field (`systemInstruction`), not part of `contents`
|
||||
2. **Message Ordering**: No requirement for system messages to be first in `contents`
|
||||
3. **Conversation Flow**: `contents` array follows chronological order with alternating user/model roles
|
||||
4. **Generation Config**: Separate `generationConfig` object for model parameters
|
||||
5. **Thinking Mode**: Available in Gemini 2.5 models via `thinkingConfig`
|
||||
|
||||
## References
|
||||
|
||||
All information in this document is sourced from official Google Gemini API documentation:
|
||||
|
||||
- **Text Generation Guide**: https://ai.google.dev/gemini-api/docs/text-generation
|
||||
- **API Reference**: https://ai.google.dev/api/generate-content
|
||||
- **System Instructions**: Examples from text generation guide showing `systemInstruction` as separate field
|
||||
- **Chat Examples**: Multi-turn conversation examples from official documentation
|
||||
- **Generation Config**: Configuration examples from official REST API documentation
|
||||
- **Thinking Configuration**: Gemini 2.5 thinking examples from official documentation
|
||||
|
||||
Each code example and API structure shown above is directly from Google's official documentation and represents the current (as of January 2025) API specification.
|
||||
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Example script demonstrating how to monkey-patch litellm to automatically
|
||||
include thinkingConfig in Gemini API calls.
|
||||
|
||||
This approach allows you to enable Gemini's thinking/reasoning capabilities
|
||||
without modifying the litellm source code.
|
||||
|
||||
This version patches both sync and async transformation functions to ensure
|
||||
compatibility with both litellm.completion() and litellm.acompletion().
|
||||
OpenHands uses the sync version, so this is important for real-world usage.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import litellm
|
||||
from litellm.llms.vertex_ai.gemini.transformation import (
|
||||
async_transform_request_body,
|
||||
sync_transform_request_body,
|
||||
)
|
||||
|
||||
|
||||
def apply_gemini_thinking_patch():
|
||||
"""
|
||||
Apply a monkey patch to litellm to automatically include thinkingConfig
|
||||
in all Gemini API calls (both sync and async).
|
||||
"""
|
||||
# Store the original transformation functions
|
||||
original_async_transform = async_transform_request_body
|
||||
original_sync_transform = sync_transform_request_body
|
||||
|
||||
# Create patched async version that adds thinkingConfig
|
||||
async def patched_async_transform_with_thinking(*args, **kwargs):
|
||||
# Add thinkingConfig to optional_params before calling the original function
|
||||
if 'optional_params' in kwargs:
|
||||
# Configure thinking settings - customize as needed
|
||||
kwargs['optional_params']['thinkingConfig'] = {
|
||||
'includeThoughts': True,
|
||||
# Add other thinking config options here if needed
|
||||
}
|
||||
# Call the original function with modified params
|
||||
return await original_async_transform(*args, **kwargs)
|
||||
|
||||
# Create patched sync version that adds thinkingConfig
|
||||
def patched_sync_transform_with_thinking(*args, **kwargs):
|
||||
# Add thinkingConfig to optional_params before calling the original function
|
||||
if 'optional_params' in kwargs:
|
||||
# Configure thinking settings - customize as needed
|
||||
kwargs['optional_params']['thinkingConfig'] = {
|
||||
'includeThoughts': True,
|
||||
# Add other thinking config options here if needed
|
||||
}
|
||||
# Call the original function with modified params
|
||||
return original_sync_transform(*args, **kwargs)
|
||||
|
||||
# Apply the monkey patches
|
||||
import litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini as gemini_module
|
||||
|
||||
gemini_module.async_transform_request_body = patched_async_transform_with_thinking
|
||||
gemini_module.sync_transform_request_body = patched_sync_transform_with_thinking
|
||||
|
||||
print('✅ Gemini thinking patch applied successfully (both sync and async)!')
|
||||
print(
|
||||
' All Gemini API calls will now include thinkingConfig with includeThoughts=True'
|
||||
)
|
||||
|
||||
return original_async_transform, original_sync_transform
|
||||
|
||||
|
||||
def remove_gemini_thinking_patch(original_functions):
|
||||
"""Remove the monkey-patch and restore original functions."""
|
||||
original_async_transform, original_sync_transform = original_functions
|
||||
import litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini as gemini_module
|
||||
|
||||
gemini_module.async_transform_request_body = original_async_transform
|
||||
gemini_module.sync_transform_request_body = original_sync_transform
|
||||
print('✅ Gemini thinking patch removed successfully!')
|
||||
|
||||
|
||||
async def example_async_usage():
|
||||
"""
|
||||
Example of using litellm.acompletion() with the thinking patch applied.
|
||||
"""
|
||||
try:
|
||||
# Make an async completion request - thinkingConfig will be automatically included
|
||||
response = await litellm.acompletion(
|
||||
model='gemini/gemini-pro',
|
||||
messages=[
|
||||
{
|
||||
'role': 'user',
|
||||
'content': 'Explain the concept of quantum entanglement in simple terms.',
|
||||
}
|
||||
],
|
||||
temperature=0.7,
|
||||
max_tokens=200,
|
||||
api_key='your-gemini-api-key-here', # Replace with your actual API key
|
||||
)
|
||||
|
||||
print('\n🔮 Async Response:')
|
||||
print(response.choices[0].message.content)
|
||||
|
||||
except Exception as e:
|
||||
print(f'❌ Error in async call: {e}')
|
||||
|
||||
|
||||
def example_sync_usage():
|
||||
"""
|
||||
Example of using litellm.completion() with the thinking patch applied.
|
||||
This is the version that OpenHands uses.
|
||||
"""
|
||||
try:
|
||||
# Make a sync completion request - thinkingConfig will be automatically included
|
||||
response = litellm.completion(
|
||||
model='gemini/gemini-pro',
|
||||
messages=[
|
||||
{
|
||||
'role': 'user',
|
||||
'content': 'What are the key principles of machine learning?',
|
||||
}
|
||||
],
|
||||
temperature=0.7,
|
||||
max_tokens=200,
|
||||
api_key='your-gemini-api-key-here', # Replace with your actual API key
|
||||
)
|
||||
|
||||
print('\n🔮 Sync Response:')
|
||||
print(response.choices[0].message.content)
|
||||
|
||||
except Exception as e:
|
||||
print(f'❌ Error in sync call: {e}')
|
||||
|
||||
|
||||
async def main():
|
||||
"""
|
||||
Main function demonstrating the complete workflow.
|
||||
"""
|
||||
print('🚀 Gemini Thinking Patch Example')
|
||||
print('=' * 40)
|
||||
|
||||
# Apply the patch
|
||||
original_functions = apply_gemini_thinking_patch()
|
||||
|
||||
try:
|
||||
print('\n📝 Testing sync completion (like OpenHands uses)...')
|
||||
example_sync_usage()
|
||||
|
||||
print('\n📝 Testing async completion...')
|
||||
await example_async_usage()
|
||||
|
||||
finally:
|
||||
# Clean up - restore original functions
|
||||
remove_gemini_thinking_patch(original_functions)
|
||||
|
||||
print('\n✨ Example completed!')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Note: You'll need to set your Gemini API key for this to work
|
||||
# export GEMINI_API_KEY="your-api-key-here"
|
||||
# or replace "your-gemini-api-key-here" in the examples above
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -1 +0,0 @@
|
||||
"""OpenHands CLI module."""
|
||||
@@ -1,54 +0,0 @@
|
||||
"""Main entry point for OpenHands CLI with subcommand support."""
|
||||
|
||||
import sys
|
||||
|
||||
import openhands
|
||||
import openhands.cli.suppress_warnings # noqa: F401
|
||||
from openhands.cli.gui_launcher import launch_gui_server
|
||||
from openhands.cli.main import run_cli_command
|
||||
from openhands.core.config import get_cli_parser
|
||||
from openhands.core.config.arg_utils import get_subparser
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point with subcommand support and backward compatibility."""
|
||||
parser = get_cli_parser()
|
||||
|
||||
# If user only asks for --help or -h without a subcommand
|
||||
if len(sys.argv) == 2 and sys.argv[1] in ('--help', '-h'):
|
||||
# Print top-level help
|
||||
print(parser.format_help())
|
||||
|
||||
# Also print help for `cli` subcommand
|
||||
print('\n' + '=' * 80)
|
||||
print('CLI command help:\n')
|
||||
|
||||
cli_parser = get_subparser(parser, 'cli')
|
||||
print(cli_parser.format_help())
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
# Special case: no subcommand provided, simulate "openhands cli"
|
||||
if len(sys.argv) == 1 or (
|
||||
len(sys.argv) > 1 and sys.argv[1] not in ['cli', 'serve']
|
||||
):
|
||||
# Inject 'cli' as default command
|
||||
sys.argv.insert(1, 'cli')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if hasattr(args, 'version') and args.version:
|
||||
print(f'OpenHands CLI version: {openhands.get_version()}')
|
||||
sys.exit(0)
|
||||
|
||||
if args.command == 'serve':
|
||||
launch_gui_server(mount_cwd=args.mount_cwd, gpu=args.gpu)
|
||||
elif args.command == 'cli' or args.command is None:
|
||||
run_cli_command(args)
|
||||
else:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,219 +0,0 @@
|
||||
"""GUI launcher for OpenHands CLI."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from prompt_toolkit import print_formatted_text
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
|
||||
from openhands import __version__
|
||||
|
||||
|
||||
def _format_docker_command_for_logging(cmd: list[str]) -> str:
|
||||
"""Format a Docker command for logging with grey color.
|
||||
|
||||
Args:
|
||||
cmd (list[str]): The Docker command as a list of strings
|
||||
|
||||
Returns:
|
||||
str: The formatted command string in grey HTML color
|
||||
"""
|
||||
cmd_str = ' '.join(cmd)
|
||||
return f'<grey>Running Docker command: {cmd_str}</grey>'
|
||||
|
||||
|
||||
def check_docker_requirements() -> bool:
|
||||
"""Check if Docker is installed and running.
|
||||
|
||||
Returns:
|
||||
bool: True if Docker is available and running, False otherwise.
|
||||
"""
|
||||
# Check if Docker is installed
|
||||
if not shutil.which('docker'):
|
||||
print_formatted_text(
|
||||
HTML('<ansired>❌ Docker is not installed or not in PATH.</ansired>')
|
||||
)
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
'<grey>Please install Docker first: https://docs.docker.com/get-docker/</grey>'
|
||||
)
|
||||
)
|
||||
return False
|
||||
|
||||
# Check if Docker daemon is running
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['docker', 'info'], capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print_formatted_text(
|
||||
HTML('<ansired>❌ Docker daemon is not running.</ansired>')
|
||||
)
|
||||
print_formatted_text(
|
||||
HTML('<grey>Please start Docker and try again.</grey>')
|
||||
)
|
||||
return False
|
||||
except (subprocess.TimeoutExpired, subprocess.SubprocessError) as e:
|
||||
print_formatted_text(
|
||||
HTML('<ansired>❌ Failed to check Docker status.</ansired>')
|
||||
)
|
||||
print_formatted_text(HTML(f'<grey>Error: {e}</grey>'))
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def ensure_config_dir_exists() -> Path:
|
||||
"""Ensure the OpenHands configuration directory exists and return its path."""
|
||||
config_dir = Path.home() / '.openhands'
|
||||
config_dir.mkdir(exist_ok=True)
|
||||
return config_dir
|
||||
|
||||
|
||||
def launch_gui_server(mount_cwd: bool = False, gpu: bool = False) -> None:
|
||||
"""Launch the OpenHands GUI server using Docker.
|
||||
|
||||
Args:
|
||||
mount_cwd: If True, mount the current working directory into the container.
|
||||
gpu: If True, enable GPU support by mounting all GPUs into the container via nvidia-docker.
|
||||
"""
|
||||
print_formatted_text(
|
||||
HTML('<ansiblue>🚀 Launching OpenHands GUI server...</ansiblue>')
|
||||
)
|
||||
print_formatted_text('')
|
||||
|
||||
# Check Docker requirements
|
||||
if not check_docker_requirements():
|
||||
sys.exit(1)
|
||||
|
||||
# Ensure config directory exists
|
||||
config_dir = ensure_config_dir_exists()
|
||||
|
||||
# Get the current version for the Docker image
|
||||
version = __version__
|
||||
runtime_image = f'docker.all-hands.dev/all-hands-ai/runtime:{version}-nikolaik'
|
||||
app_image = f'docker.all-hands.dev/all-hands-ai/openhands:{version}'
|
||||
|
||||
print_formatted_text(HTML('<grey>Pulling required Docker images...</grey>'))
|
||||
|
||||
# Pull the runtime image first
|
||||
pull_cmd = ['docker', 'pull', runtime_image]
|
||||
print_formatted_text(HTML(_format_docker_command_for_logging(pull_cmd)))
|
||||
try:
|
||||
subprocess.run(
|
||||
pull_cmd,
|
||||
check=True,
|
||||
timeout=300, # 5 minutes timeout
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
print_formatted_text(
|
||||
HTML('<ansired>❌ Failed to pull runtime image.</ansired>')
|
||||
)
|
||||
sys.exit(1)
|
||||
except subprocess.TimeoutExpired:
|
||||
print_formatted_text(
|
||||
HTML('<ansired>❌ Timeout while pulling runtime image.</ansired>')
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
print_formatted_text('')
|
||||
print_formatted_text(
|
||||
HTML('<ansigreen>✅ Starting OpenHands GUI server...</ansigreen>')
|
||||
)
|
||||
print_formatted_text(
|
||||
HTML('<grey>The server will be available at: http://localhost:3000</grey>')
|
||||
)
|
||||
print_formatted_text(HTML('<grey>Press Ctrl+C to stop the server.</grey>'))
|
||||
print_formatted_text('')
|
||||
|
||||
# Build the Docker command
|
||||
docker_cmd = [
|
||||
'docker',
|
||||
'run',
|
||||
'-it',
|
||||
'--rm',
|
||||
'--pull=always',
|
||||
'-e',
|
||||
f'SANDBOX_RUNTIME_CONTAINER_IMAGE={runtime_image}',
|
||||
'-e',
|
||||
'LOG_ALL_EVENTS=true',
|
||||
'-v',
|
||||
'/var/run/docker.sock:/var/run/docker.sock',
|
||||
'-v',
|
||||
f'{config_dir}:/.openhands',
|
||||
]
|
||||
|
||||
# Add GPU support if requested
|
||||
if gpu:
|
||||
print_formatted_text(
|
||||
HTML('<ansigreen>🖥️ Enabling GPU support via nvidia-docker...</ansigreen>')
|
||||
)
|
||||
# Add the --gpus all flag to enable all GPUs
|
||||
docker_cmd.insert(2, '--gpus')
|
||||
docker_cmd.insert(3, 'all')
|
||||
# Add environment variable to pass GPU support to sandbox containers
|
||||
docker_cmd.extend(
|
||||
[
|
||||
'-e',
|
||||
'SANDBOX_ENABLE_GPU=true',
|
||||
]
|
||||
)
|
||||
|
||||
# Add current working directory mount if requested
|
||||
if mount_cwd:
|
||||
cwd = Path.cwd()
|
||||
# Following the documentation at https://docs.all-hands.dev/usage/runtimes/docker#connecting-to-your-filesystem
|
||||
docker_cmd.extend(
|
||||
[
|
||||
'-e',
|
||||
f'SANDBOX_VOLUMES={cwd}:/workspace:rw',
|
||||
]
|
||||
)
|
||||
|
||||
# Set user ID for Unix-like systems only
|
||||
if os.name != 'nt': # Not Windows
|
||||
try:
|
||||
user_id = subprocess.check_output(['id', '-u'], text=True).strip()
|
||||
docker_cmd.extend(['-e', f'SANDBOX_USER_ID={user_id}'])
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
# If 'id' command fails or doesn't exist, skip setting user ID
|
||||
pass
|
||||
# Print the folder that will be mounted to inform the user
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
f'<ansigreen>📂 Mounting current directory:</ansigreen> <ansiyellow>{cwd}</ansiyellow> <ansigreen>to</ansigreen> <ansiyellow>/workspace</ansiyellow>'
|
||||
)
|
||||
)
|
||||
|
||||
docker_cmd.extend(
|
||||
[
|
||||
'-p',
|
||||
'3000:3000',
|
||||
'--add-host',
|
||||
'host.docker.internal:host-gateway',
|
||||
'--name',
|
||||
'openhands-app',
|
||||
app_image,
|
||||
]
|
||||
)
|
||||
|
||||
try:
|
||||
# Log and run the Docker command
|
||||
print_formatted_text(HTML(_format_docker_command_for_logging(docker_cmd)))
|
||||
subprocess.run(docker_cmd, check=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print_formatted_text('')
|
||||
print_formatted_text(
|
||||
HTML('<ansired>❌ Failed to start OpenHands GUI server.</ansired>')
|
||||
)
|
||||
print_formatted_text(HTML(f'<grey>Error: {e}</grey>'))
|
||||
sys.exit(1)
|
||||
except KeyboardInterrupt:
|
||||
print_formatted_text('')
|
||||
print_formatted_text(
|
||||
HTML('<ansigreen>✓ OpenHands GUI server stopped successfully.</ansigreen>')
|
||||
)
|
||||
sys.exit(0)
|
||||
+18
-24
@@ -45,6 +45,7 @@ from openhands.controller import AgentController
|
||||
from openhands.controller.agent import Agent
|
||||
from openhands.core.config import (
|
||||
OpenHandsConfig,
|
||||
parse_arguments,
|
||||
setup_config_from_args,
|
||||
)
|
||||
from openhands.core.config.condenser_config import NoOpCondenserConfig
|
||||
@@ -523,8 +524,10 @@ def run_alias_setup_flow(config: OpenHandsConfig) -> None:
|
||||
print_formatted_text('')
|
||||
|
||||
|
||||
async def main_with_loop(loop: asyncio.AbstractEventLoop, args) -> None:
|
||||
async def main_with_loop(loop: asyncio.AbstractEventLoop) -> None:
|
||||
"""Runs the agent in CLI mode."""
|
||||
args = parse_arguments()
|
||||
|
||||
# Set log level from command line argument if provided
|
||||
if args.log_level and isinstance(args.log_level, str):
|
||||
log_level = getattr(logging, str(args.log_level).upper())
|
||||
@@ -572,9 +575,13 @@ async def main_with_loop(loop: asyncio.AbstractEventLoop, args) -> None:
|
||||
|
||||
# Use settings from settings store if available and override with command line arguments
|
||||
if settings:
|
||||
# settings.agent is not None because we check for it in setup_config_from_args
|
||||
assert settings.agent is not None
|
||||
config.default_agent = settings.agent
|
||||
# Handle agent configuration
|
||||
if args.agent_cls:
|
||||
config.default_agent = str(args.agent_cls)
|
||||
else:
|
||||
# settings.agent is not None because we check for it in setup_config_from_args
|
||||
assert settings.agent is not None
|
||||
config.default_agent = settings.agent
|
||||
|
||||
# Handle LLM configuration with proper precedence:
|
||||
# 1. CLI parameters (-l) have highest precedence (already handled in setup_config_from_args)
|
||||
@@ -712,19 +719,18 @@ After reviewing the file, please ask the user what they would like to do with it
|
||||
get_runtime_cls(config.runtime).teardown(config)
|
||||
|
||||
|
||||
def run_cli_command(args):
|
||||
"""Run the CLI command with proper error handling and cleanup."""
|
||||
def main():
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
loop.run_until_complete(main_with_loop(loop, args))
|
||||
loop.run_until_complete(main_with_loop(loop))
|
||||
except KeyboardInterrupt:
|
||||
print_formatted_text('⚠️ Session was interrupted: interrupted\n')
|
||||
except ConnectionRefusedError as e:
|
||||
print_formatted_text(f'Connection refused: {e}')
|
||||
print(f'Connection refused: {e}')
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print_formatted_text(f'An error occurred: {e}')
|
||||
print(f'An error occurred: {e}')
|
||||
sys.exit(1)
|
||||
finally:
|
||||
try:
|
||||
@@ -737,21 +743,9 @@ def run_cli_command(args):
|
||||
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
||||
loop.close()
|
||||
except Exception as e:
|
||||
print_formatted_text(f'Error during cleanup: {e}')
|
||||
print(f'Error during cleanup: {e}')
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for OpenHands CLI."""
|
||||
from openhands.core.config import get_cli_parser
|
||||
|
||||
parser = get_cli_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
if hasattr(args, 'version') and args.version:
|
||||
import openhands
|
||||
|
||||
print(f'OpenHands CLI version: {openhands.get_version()}')
|
||||
sys.exit(0)
|
||||
|
||||
run_cli_command(args)
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
@@ -150,7 +150,6 @@ def organize_models_and_providers(
|
||||
VERIFIED_PROVIDERS = ['openhands', 'anthropic', 'openai', 'mistral']
|
||||
|
||||
VERIFIED_OPENAI_MODELS = [
|
||||
'gpt-5-2025-08-07',
|
||||
'o4-mini',
|
||||
'gpt-4o',
|
||||
'gpt-4o-mini',
|
||||
@@ -185,7 +184,6 @@ VERIFIED_MISTRAL_MODELS = [
|
||||
|
||||
VERIFIED_OPENHANDS_MODELS = [
|
||||
'claude-sonnet-4-20250514',
|
||||
'gpt-5-2025-08-07',
|
||||
'claude-opus-4-20250514',
|
||||
'claude-opus-4-1-20250805',
|
||||
'devstral-small-2507',
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
from openhands.core.config.agent_config import AgentConfig
|
||||
from openhands.core.config.arg_utils import (
|
||||
get_cli_parser,
|
||||
get_evaluation_parser,
|
||||
get_headless_parser,
|
||||
)
|
||||
from openhands.core.config.cli_config import CLIConfig
|
||||
from openhands.core.config.config_utils import (
|
||||
OH_DEFAULT_AGENT,
|
||||
@@ -20,6 +15,7 @@ from openhands.core.config.utils import (
|
||||
finalize_config,
|
||||
get_agent_config_arg,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
load_from_env,
|
||||
load_from_toml,
|
||||
load_openhands_config,
|
||||
@@ -45,9 +41,7 @@ __all__ = [
|
||||
'get_agent_config_arg',
|
||||
'get_llm_config_arg',
|
||||
'get_field_info',
|
||||
'get_cli_parser',
|
||||
'get_headless_parser',
|
||||
'get_evaluation_parser',
|
||||
'get_parser',
|
||||
'parse_arguments',
|
||||
'setup_config_from_args',
|
||||
]
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
"""Centralized command line argument configuration for OpenHands CLI and headless modes."""
|
||||
|
||||
import argparse
|
||||
from argparse import ArgumentParser, _SubParsersAction
|
||||
|
||||
|
||||
def get_subparser(parser: ArgumentParser, name: str) -> ArgumentParser:
|
||||
for action in parser._actions:
|
||||
if isinstance(action, _SubParsersAction):
|
||||
if name in action.choices:
|
||||
return action.choices[name]
|
||||
raise ValueError(f"Subparser '{name}' not found")
|
||||
|
||||
|
||||
def add_common_arguments(parser: argparse.ArgumentParser) -> None:
|
||||
"""Add common arguments shared between CLI and headless modes."""
|
||||
parser.add_argument(
|
||||
'--config-file',
|
||||
type=str,
|
||||
default='config.toml',
|
||||
help='Path to the config file (default: config.toml in the current directory)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'-t',
|
||||
'--task',
|
||||
type=str,
|
||||
default='',
|
||||
help='The task for the agent to perform',
|
||||
)
|
||||
parser.add_argument(
|
||||
'-f',
|
||||
'--file',
|
||||
type=str,
|
||||
help='Path to a file containing the task. Overrides -t if both are provided.',
|
||||
)
|
||||
parser.add_argument(
|
||||
'-n',
|
||||
'--name',
|
||||
help='Session name',
|
||||
type=str,
|
||||
default='',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--log-level',
|
||||
help='Set the log level',
|
||||
type=str,
|
||||
default=None,
|
||||
)
|
||||
parser.add_argument(
|
||||
'-l',
|
||||
'--llm-config',
|
||||
default=None,
|
||||
type=str,
|
||||
help='Replace default LLM ([llm] section in config.toml) config with the specified LLM config, e.g. "llama3" for [llm.llama3] section in config.toml',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--agent-config',
|
||||
default=None,
|
||||
type=str,
|
||||
help='Replace default Agent ([agent] section in config.toml) config with the specified Agent config, e.g. "CodeAct" for [agent.CodeAct] section in config.toml',
|
||||
)
|
||||
parser.add_argument(
|
||||
'-v', '--version', action='store_true', help='Show version information'
|
||||
)
|
||||
|
||||
|
||||
def add_evaluation_arguments(parser: argparse.ArgumentParser) -> None:
|
||||
"""Add arguments specific to evaluation mode."""
|
||||
# Evaluation-specific arguments
|
||||
parser.add_argument(
|
||||
'--eval-output-dir',
|
||||
default='evaluation/evaluation_outputs/outputs',
|
||||
type=str,
|
||||
help='The directory to save evaluation output',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--eval-n-limit',
|
||||
default=None,
|
||||
type=int,
|
||||
help='The number of instances to evaluate',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--eval-num-workers',
|
||||
default=4,
|
||||
type=int,
|
||||
help='The number of workers to use for evaluation',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--eval-note',
|
||||
default=None,
|
||||
type=str,
|
||||
help='The note to add to the evaluation directory',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--eval-ids',
|
||||
default=None,
|
||||
type=str,
|
||||
help='The comma-separated list (in quotes) of IDs of the instances to evaluate',
|
||||
)
|
||||
|
||||
|
||||
def add_headless_specific_arguments(parser: argparse.ArgumentParser) -> None:
|
||||
"""Add arguments specific to headless mode (full evaluation suite)."""
|
||||
parser.add_argument(
|
||||
'-d',
|
||||
'--directory',
|
||||
type=str,
|
||||
help='The working directory for the agent',
|
||||
)
|
||||
parser.add_argument(
|
||||
'-c',
|
||||
'--agent-cls',
|
||||
default=None,
|
||||
type=str,
|
||||
help='Name of the default agent to use',
|
||||
)
|
||||
parser.add_argument(
|
||||
'-i',
|
||||
'--max-iterations',
|
||||
default=None,
|
||||
type=int,
|
||||
help='The maximum number of iterations to run the agent',
|
||||
)
|
||||
parser.add_argument(
|
||||
'-b',
|
||||
'--max-budget-per-task',
|
||||
type=float,
|
||||
help='The maximum budget allowed per task, beyond which the agent will stop.',
|
||||
)
|
||||
# Additional headless-specific arguments
|
||||
parser.add_argument(
|
||||
'--no-auto-continue',
|
||||
help='Disable auto-continue responses in headless mode (i.e. headless will read from stdin instead of auto-continuing)',
|
||||
action='store_true',
|
||||
default=False,
|
||||
)
|
||||
parser.add_argument(
|
||||
'--selected-repo',
|
||||
help='GitHub repository to clone (format: owner/repo)',
|
||||
type=str,
|
||||
default=None,
|
||||
)
|
||||
|
||||
|
||||
def get_cli_parser() -> argparse.ArgumentParser:
|
||||
"""Create argument parser for CLI mode with simplified argument set."""
|
||||
# Create a description with welcome message explaining available commands
|
||||
description = (
|
||||
'Welcome to OpenHands: Code Less, Make More\n\n'
|
||||
'OpenHands supports two main commands:\n'
|
||||
' serve - Launch the OpenHands GUI server (web interface)\n'
|
||||
' cli - Run OpenHands in CLI mode (terminal interface)\n\n'
|
||||
'Running "openhands" without a command is the same as "openhands cli"'
|
||||
)
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description=description,
|
||||
prog='openhands',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter, # Preserve formatting in description
|
||||
epilog='For more information about a command, run: openhands COMMAND --help',
|
||||
)
|
||||
|
||||
# Create subparsers
|
||||
subparsers = parser.add_subparsers(
|
||||
dest='command',
|
||||
title='commands',
|
||||
description='OpenHands supports two main commands:',
|
||||
metavar='COMMAND',
|
||||
)
|
||||
|
||||
# Add 'serve' subcommand
|
||||
serve_parser = subparsers.add_parser(
|
||||
'serve', help='Launch the OpenHands GUI server using Docker (web interface)'
|
||||
)
|
||||
serve_parser.add_argument(
|
||||
'--mount-cwd',
|
||||
help='Mount the current working directory into the GUI server container',
|
||||
action='store_true',
|
||||
default=False,
|
||||
)
|
||||
serve_parser.add_argument(
|
||||
'--gpu',
|
||||
help='Enable GPU support by mounting all GPUs into the Docker container via nvidia-docker',
|
||||
action='store_true',
|
||||
default=False,
|
||||
)
|
||||
|
||||
# Add 'cli' subcommand - import all the existing CLI arguments
|
||||
cli_parser = subparsers.add_parser(
|
||||
'cli', help='Run OpenHands in CLI mode (terminal interface)'
|
||||
)
|
||||
add_common_arguments(cli_parser)
|
||||
|
||||
cli_parser.add_argument(
|
||||
'--override-cli-mode',
|
||||
help='Override the default settings for CLI mode',
|
||||
type=bool,
|
||||
default=False,
|
||||
)
|
||||
parser.add_argument(
|
||||
'--conversation',
|
||||
help='The conversation id to continue',
|
||||
type=str,
|
||||
default=None,
|
||||
)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def get_headless_parser() -> argparse.ArgumentParser:
|
||||
"""Create argument parser for headless mode with full argument set."""
|
||||
parser = argparse.ArgumentParser(description='Run the agent via CLI')
|
||||
add_common_arguments(parser)
|
||||
add_headless_specific_arguments(parser)
|
||||
return parser
|
||||
|
||||
|
||||
def get_evaluation_parser() -> argparse.ArgumentParser:
|
||||
"""Create argument parser for evaluation mode."""
|
||||
parser = argparse.ArgumentParser(description='Run OpenHands in evaluation mode')
|
||||
add_common_arguments(parser)
|
||||
add_headless_specific_arguments(parser)
|
||||
add_evaluation_arguments(parser)
|
||||
return parser
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
@@ -304,13 +302,6 @@ class MCPConfig(BaseModel):
|
||||
raise ValueError(f'Invalid MCP configuration: {e}')
|
||||
return mcp_mapping
|
||||
|
||||
def merge(self, other: MCPConfig):
|
||||
return MCPConfig(
|
||||
sse_servers=self.sse_servers + other.sse_servers,
|
||||
stdio_servers=self.stdio_servers + other.stdio_servers,
|
||||
shttp_servers=self.shttp_servers + other.shttp_servers,
|
||||
)
|
||||
|
||||
|
||||
class OpenHandsMCPConfig:
|
||||
@staticmethod
|
||||
|
||||
@@ -15,7 +15,6 @@ from pydantic import BaseModel, SecretStr, ValidationError
|
||||
from openhands import __version__
|
||||
from openhands.core import logger
|
||||
from openhands.core.config.agent_config import AgentConfig
|
||||
from openhands.core.config.arg_utils import get_headless_parser
|
||||
from openhands.core.config.condenser_config import (
|
||||
CondenserConfig,
|
||||
condenser_config_from_toml_section,
|
||||
@@ -671,9 +670,148 @@ def get_condenser_config_arg(
|
||||
return None
|
||||
|
||||
|
||||
# Command line arguments
|
||||
def get_parser() -> argparse.ArgumentParser:
|
||||
"""Get the argument parser."""
|
||||
parser = argparse.ArgumentParser(description='Run the agent via CLI')
|
||||
|
||||
# Add version argument
|
||||
parser.add_argument(
|
||||
'-v', '--version', action='store_true', help='Show version information'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--config-file',
|
||||
type=str,
|
||||
default='config.toml',
|
||||
help='Path to the config file (default: config.toml in the current directory)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'-d',
|
||||
'--directory',
|
||||
type=str,
|
||||
help='The working directory for the agent',
|
||||
)
|
||||
parser.add_argument(
|
||||
'-t',
|
||||
'--task',
|
||||
type=str,
|
||||
default='',
|
||||
help='The task for the agent to perform',
|
||||
)
|
||||
parser.add_argument(
|
||||
'-f',
|
||||
'--file',
|
||||
type=str,
|
||||
help='Path to a file containing the task. Overrides -t if both are provided.',
|
||||
)
|
||||
parser.add_argument(
|
||||
'-c',
|
||||
'--agent-cls',
|
||||
default=None,
|
||||
type=str,
|
||||
help='Name of the default agent to use',
|
||||
)
|
||||
parser.add_argument(
|
||||
'-i',
|
||||
'--max-iterations',
|
||||
default=None,
|
||||
type=int,
|
||||
help='The maximum number of iterations to run the agent',
|
||||
)
|
||||
parser.add_argument(
|
||||
'-b',
|
||||
'--max-budget-per-task',
|
||||
type=float,
|
||||
help='The maximum budget allowed per task, beyond which the agent will stop.',
|
||||
)
|
||||
# --eval configs are for evaluations only
|
||||
parser.add_argument(
|
||||
'--eval-output-dir',
|
||||
default='evaluation/evaluation_outputs/outputs',
|
||||
type=str,
|
||||
help='The directory to save evaluation output',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--eval-n-limit',
|
||||
default=None,
|
||||
type=int,
|
||||
help='The number of instances to evaluate',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--eval-num-workers',
|
||||
default=4,
|
||||
type=int,
|
||||
help='The number of workers to use for evaluation',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--eval-note',
|
||||
default=None,
|
||||
type=str,
|
||||
help='The note to add to the evaluation directory',
|
||||
)
|
||||
parser.add_argument(
|
||||
'-l',
|
||||
'--llm-config',
|
||||
default=None,
|
||||
type=str,
|
||||
help='Replace default LLM ([llm] section in config.toml) config with the specified LLM config, e.g. "llama3" for [llm.llama3] section in config.toml',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--agent-config',
|
||||
default=None,
|
||||
type=str,
|
||||
help='Replace default Agent ([agent] section in config.toml) config with the specified Agent config, e.g. "CodeAct" for [agent.CodeAct] section in config.toml',
|
||||
)
|
||||
parser.add_argument(
|
||||
'-n',
|
||||
'--name',
|
||||
help='Session name',
|
||||
type=str,
|
||||
default='',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--conversation',
|
||||
help='The conversation id to continue',
|
||||
type=str,
|
||||
default=None,
|
||||
)
|
||||
parser.add_argument(
|
||||
'--eval-ids',
|
||||
default=None,
|
||||
type=str,
|
||||
help='The comma-separated list (in quotes) of IDs of the instances to evaluate',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--no-auto-continue',
|
||||
help='Disable auto-continue responses in headless mode (i.e. headless will read from stdin instead of auto-continuing)',
|
||||
action='store_true',
|
||||
default=False,
|
||||
)
|
||||
parser.add_argument(
|
||||
'--selected-repo',
|
||||
help='GitHub repository to clone (format: owner/repo)',
|
||||
type=str,
|
||||
default=None,
|
||||
)
|
||||
parser.add_argument(
|
||||
'--override-cli-mode',
|
||||
help='Override the default settings for CLI mode',
|
||||
type=bool,
|
||||
default=False,
|
||||
)
|
||||
parser.add_argument(
|
||||
'--log-level',
|
||||
help='Set the log level',
|
||||
type=str,
|
||||
default=None,
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def parse_arguments() -> argparse.Namespace:
|
||||
"""Parse command line arguments."""
|
||||
parser = get_headless_parser()
|
||||
parser = get_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.version:
|
||||
@@ -778,17 +916,17 @@ def setup_config_from_args(args: argparse.Namespace) -> OpenHandsConfig:
|
||||
)
|
||||
|
||||
# Override default agent if provided
|
||||
if hasattr(args, 'agent_cls') and args.agent_cls:
|
||||
if args.agent_cls:
|
||||
config.default_agent = args.agent_cls
|
||||
|
||||
# Set max iterations and max budget per task if provided, otherwise fall back to config values
|
||||
if hasattr(args, 'max_iterations') and args.max_iterations is not None:
|
||||
if args.max_iterations is not None:
|
||||
config.max_iterations = args.max_iterations
|
||||
if hasattr(args, 'max_budget_per_task') and args.max_budget_per_task is not None:
|
||||
if args.max_budget_per_task is not None:
|
||||
config.max_budget_per_task = args.max_budget_per_task
|
||||
|
||||
# Read selected repository in config for use by CLI and main.py
|
||||
if hasattr(args, 'selected_repo') and args.selected_repo is not None:
|
||||
if args.selected_repo is not None:
|
||||
config.sandbox.selected_repo = args.selected_repo
|
||||
|
||||
return config
|
||||
|
||||
+121
-17
@@ -94,7 +94,6 @@ FUNCTION_CALLING_SUPPORTED_MODELS = [
|
||||
'kimi-k2-instruct',
|
||||
'Qwen3-Coder-480B-A35B-Instruct',
|
||||
'qwen3-coder', # this will match both qwen3-coder-480b (openhands provider) and qwen3-coder (for openrouter)
|
||||
'gpt-5-2025-08-07',
|
||||
]
|
||||
|
||||
REASONING_EFFORT_SUPPORTED_MODELS = [
|
||||
@@ -108,7 +107,6 @@ REASONING_EFFORT_SUPPORTED_MODELS = [
|
||||
'o4-mini-2025-04-16',
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.5-pro',
|
||||
'gpt-5-2025-08-07',
|
||||
]
|
||||
|
||||
MODELS_WITHOUT_STOP_WORDS = [
|
||||
@@ -203,26 +201,34 @@ class LLM(RetryMixin, DebugMixin):
|
||||
):
|
||||
# For Gemini models, only map 'low' to optimized thinking budget
|
||||
# Let other reasoning_effort values pass through to API as-is
|
||||
# RESTORED: Direct kwargs approach - testing direct kwargs only
|
||||
if 'gemini-2.5-pro' in self.config.model:
|
||||
logger.debug(
|
||||
f'Gemini model {self.config.model} with reasoning_effort {self.config.reasoning_effort}'
|
||||
f'Applying custom generation config for {self.config.model}'
|
||||
)
|
||||
if self.config.reasoning_effort in {None, 'low', 'none'}:
|
||||
kwargs['thinking'] = {'budget_tokens': 128}
|
||||
kwargs['allowed_openai_params'] = ['thinking']
|
||||
kwargs.pop('reasoning_effort', None)
|
||||
else:
|
||||
kwargs['reasoning_effort'] = self.config.reasoning_effort
|
||||
logger.debug(
|
||||
f'Gemini model {self.config.model} with reasoning_effort {self.config.reasoning_effort} mapped to thinking {kwargs.get("thinking")}'
|
||||
)
|
||||
|
||||
kwargs['generationConfig'] = {
|
||||
'temperature': 0, # Put temperature in generationConfig instead of top-level
|
||||
'topP': 1,
|
||||
'thinkingConfig': {'includeThoughts': True},
|
||||
}
|
||||
# These are now inside generationConfig, so remove them from top-level
|
||||
kwargs.pop(
|
||||
'temperature', None
|
||||
) # Remove top-level temperature since it's now in generationConfig
|
||||
kwargs.pop(
|
||||
'top_p', None
|
||||
) # Remove top_p since it's in generationConfig as topP
|
||||
# This is now inside thinkingConfig, so remove it from top-level
|
||||
kwargs.pop('reasoning_effort', None)
|
||||
# remove other related params that are no longer needed
|
||||
kwargs.pop('thinking', None)
|
||||
kwargs.pop('allowed_openai_params', None)
|
||||
else:
|
||||
kwargs['reasoning_effort'] = self.config.reasoning_effort
|
||||
kwargs.pop(
|
||||
'temperature'
|
||||
) # temperature is not supported for reasoning models
|
||||
kwargs.pop('top_p') # reasoning model like o3 doesn't support top_p
|
||||
kwargs.pop(
|
||||
'temperature'
|
||||
) # temperature is not supported for reasoning models
|
||||
kwargs.pop('top_p') # reasoning model like o3 doesn't support top_p
|
||||
# Azure issue: https://github.com/All-Hands-AI/OpenHands/issues/6777
|
||||
if self.config.model.startswith('azure'):
|
||||
kwargs['max_tokens'] = self.config.max_output_tokens
|
||||
@@ -327,6 +333,7 @@ class LLM(RetryMixin, DebugMixin):
|
||||
|
||||
# log the entire LLM prompt
|
||||
self.log_prompt(messages)
|
||||
print(self.config.model)
|
||||
|
||||
# set litellm modify_params to the configured value
|
||||
# True by default to allow litellm to do transformations like adding a default message, when a message is empty
|
||||
@@ -353,6 +360,8 @@ class LLM(RetryMixin, DebugMixin):
|
||||
message=r'.*content=.*upload.*',
|
||||
category=DeprecationWarning,
|
||||
)
|
||||
# COMMENTED OUT: Context manager approach - testing direct kwargs only
|
||||
# with self._gemini_thinking_patch_context():
|
||||
resp: ModelResponse = self._completion_unwrapped(*args, **kwargs)
|
||||
|
||||
# Calculate and record latency
|
||||
@@ -435,6 +444,101 @@ class LLM(RetryMixin, DebugMixin):
|
||||
|
||||
self._completion = wrapper
|
||||
|
||||
def _should_apply_gemini_thinking_patch(self) -> bool:
|
||||
"""Check if we should apply the Gemini thinking patch.
|
||||
|
||||
Returns True for Gemini 2.5 Pro models to enable thinking capabilities.
|
||||
"""
|
||||
return 'gemini-2.5-pro' in self.config.model.lower()
|
||||
|
||||
def _gemini_thinking_patch_context(self):
|
||||
"""Context manager that temporarily applies Gemini thinking patch.
|
||||
|
||||
This ensures the patch is only active during the specific completion call
|
||||
and is automatically cleaned up afterwards, preventing interference with
|
||||
other models or subsequent calls.
|
||||
"""
|
||||
from contextlib import contextmanager
|
||||
|
||||
@contextmanager
|
||||
def patch_context():
|
||||
if not self._should_apply_gemini_thinking_patch():
|
||||
# No patch needed, just yield
|
||||
yield
|
||||
return
|
||||
|
||||
# Store original functions for restoration
|
||||
original_sync_transform = None
|
||||
original_async_transform = None
|
||||
gemini_module = None
|
||||
|
||||
patch_applied = False
|
||||
try:
|
||||
# Import the modules we need to patch
|
||||
import litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini as gemini_mod
|
||||
|
||||
gemini_module = gemini_mod
|
||||
|
||||
# Store original functions
|
||||
original_sync_transform = gemini_module.sync_transform_request_body
|
||||
original_async_transform = getattr(
|
||||
gemini_module, 'async_transform_request_body', None
|
||||
)
|
||||
|
||||
# Create patched sync version
|
||||
def patched_sync_transform_with_thinking(*args, **kwargs):
|
||||
if 'optional_params' in kwargs:
|
||||
kwargs['optional_params']['thinkingConfig'] = {
|
||||
'includeThoughts': True,
|
||||
}
|
||||
return original_sync_transform(*args, **kwargs)
|
||||
|
||||
# Create patched async version if it exists
|
||||
async def patched_async_transform_with_thinking(*args, **kwargs):
|
||||
if 'optional_params' in kwargs:
|
||||
kwargs['optional_params']['thinkingConfig'] = {
|
||||
'includeThoughts': True,
|
||||
}
|
||||
if original_async_transform is not None:
|
||||
return await original_async_transform(*args, **kwargs)
|
||||
return None
|
||||
|
||||
# Apply patches
|
||||
gemini_module.sync_transform_request_body = (
|
||||
patched_sync_transform_with_thinking
|
||||
)
|
||||
if original_async_transform:
|
||||
gemini_module.async_transform_request_body = (
|
||||
patched_async_transform_with_thinking
|
||||
)
|
||||
|
||||
patch_applied = True
|
||||
logger.debug(
|
||||
f'Applied temporary Gemini thinking patch for model: {self.config.model}'
|
||||
)
|
||||
|
||||
except ImportError as e:
|
||||
logger.warning(f'Could not apply Gemini thinking patch: {e}')
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to apply Gemini thinking patch: {e}')
|
||||
|
||||
try:
|
||||
# Yield control to the caller
|
||||
yield
|
||||
finally:
|
||||
# Always restore original functions if patch was applied
|
||||
if patch_applied and gemini_module and original_sync_transform:
|
||||
gemini_module.sync_transform_request_body = original_sync_transform
|
||||
logger.debug('Restored original sync_transform_request_body')
|
||||
|
||||
if patch_applied and gemini_module and original_async_transform:
|
||||
gemini_module.async_transform_request_body = (
|
||||
original_async_transform
|
||||
)
|
||||
logger.debug('Restored original async_transform_request_body')
|
||||
|
||||
return patch_context()
|
||||
|
||||
@property
|
||||
def completion(self) -> Callable:
|
||||
"""Decorator for the litellm completion function.
|
||||
|
||||
@@ -10,18 +10,17 @@ from jinja2 import Environment, FileSystemLoader
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from openhands.core.config.llm_config import LLMConfig
|
||||
from openhands.core.config.mcp_config import MCPConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action import (
|
||||
ChangeAgentStateAction,
|
||||
NullAction,
|
||||
)
|
||||
from openhands.events.event_filter import EventFilter
|
||||
from openhands.events.event_store import EventStore
|
||||
from openhands.events.observation import (
|
||||
AgentStateChangedObservation,
|
||||
NullObservation,
|
||||
)
|
||||
from openhands.events.stream import EventStream
|
||||
from openhands.integrations.provider import (
|
||||
PROVIDER_TOKEN_TYPE,
|
||||
ProviderHandler,
|
||||
@@ -45,11 +44,11 @@ from openhands.server.services.conversation_service import (
|
||||
create_new_conversation,
|
||||
setup_init_convo_settings,
|
||||
)
|
||||
from openhands.server.session.conversation import ServerConversation
|
||||
from openhands.server.shared import (
|
||||
ConversationStoreImpl,
|
||||
config,
|
||||
conversation_manager,
|
||||
file_store,
|
||||
)
|
||||
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
|
||||
from openhands.server.user_auth import (
|
||||
@@ -61,7 +60,7 @@ from openhands.server.user_auth import (
|
||||
get_user_settings_store,
|
||||
)
|
||||
from openhands.server.user_auth.user_auth import AuthType
|
||||
from openhands.server.utils import get_conversation as get_conversation_metadata
|
||||
from openhands.server.utils import get_conversation as get_conversation_object
|
||||
from openhands.server.utils import get_conversation_store
|
||||
from openhands.storage.conversation.conversation_store import ConversationStore
|
||||
from openhands.storage.data_models.conversation_metadata import (
|
||||
@@ -88,7 +87,6 @@ class InitSessionRequest(BaseModel):
|
||||
suggested_task: SuggestedTask | None = None
|
||||
create_microagent: CreateMicroagent | None = None
|
||||
conversation_instructions: str | None = None
|
||||
mcp_config: MCPConfig | None = None
|
||||
# Only nested runtimes require the ability to specify a conversation id, and it could be a security risk
|
||||
if os.getenv('ALLOW_SET_CONVERSATION_ID', '0') == '1':
|
||||
conversation_id: str = Field(default_factory=lambda: uuid.uuid4().hex)
|
||||
@@ -180,7 +178,6 @@ async def new_conversation(
|
||||
conversation_instructions=conversation_instructions,
|
||||
git_provider=git_provider,
|
||||
conversation_id=conversation_id,
|
||||
mcp_config=data.mcp_config,
|
||||
)
|
||||
|
||||
return ConversationResponse(
|
||||
@@ -334,20 +331,23 @@ async def delete_conversation(
|
||||
return True
|
||||
|
||||
|
||||
@app.get('/conversations/{conversation_id}/remember-prompt')
|
||||
@app.get('/conversations/{conversation_id}/remember_prompt')
|
||||
async def get_prompt(
|
||||
conversation_id: str,
|
||||
event_id: int,
|
||||
user_settings: SettingsStore = Depends(get_user_settings_store),
|
||||
metadata: ConversationMetadata = Depends(get_conversation_metadata),
|
||||
conversation: ServerConversation | None = Depends(get_conversation_object),
|
||||
):
|
||||
# get event store for the conversation
|
||||
event_store = EventStore(
|
||||
sid=conversation_id, file_store=file_store, user_id=metadata.user_id
|
||||
)
|
||||
if conversation is None:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={'error': 'Conversation not found.'},
|
||||
)
|
||||
|
||||
# get event stream for the conversation
|
||||
event_stream = conversation.event_stream
|
||||
|
||||
# retrieve the relevant events
|
||||
stringified_events = _get_contextual_events(event_store, event_id)
|
||||
stringified_events = _get_contextual_events(event_stream, event_id)
|
||||
|
||||
# generate a prompt
|
||||
settings = await user_settings.load()
|
||||
@@ -551,7 +551,7 @@ async def stop_conversation(
|
||||
)
|
||||
|
||||
|
||||
def _get_contextual_events(event_store: EventStore, event_id: int) -> str:
|
||||
def _get_contextual_events(event_stream: EventStream, event_id: int) -> str:
|
||||
# find the specified events to learn from
|
||||
# Get X events around the target event
|
||||
context_size = 4
|
||||
@@ -567,7 +567,7 @@ def _get_contextual_events(event_store: EventStore, event_id: int) -> str:
|
||||
) # the types of events that can be in an agent's history
|
||||
|
||||
# from event_id - context_size to event_id..
|
||||
context_before = event_store.search_events(
|
||||
context_before = event_stream.search_events(
|
||||
start_id=event_id,
|
||||
filter=agent_event_filter,
|
||||
reverse=True,
|
||||
@@ -575,7 +575,7 @@ def _get_contextual_events(event_store: EventStore, event_id: int) -> str:
|
||||
)
|
||||
|
||||
# from event_id to event_id + context_size + 1
|
||||
context_after = event_store.search_events(
|
||||
context_after = event_stream.search_events(
|
||||
start_id=event_id + 1,
|
||||
filter=agent_event_filter,
|
||||
limit=context_size + 1,
|
||||
|
||||
@@ -2,7 +2,6 @@ import uuid
|
||||
from types import MappingProxyType
|
||||
from typing import Any
|
||||
|
||||
from openhands.core.config.mcp_config import MCPConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action.message import MessageAction
|
||||
from openhands.experiments.experiment_manager import ExperimentManagerImpl
|
||||
@@ -45,7 +44,6 @@ async def create_new_conversation(
|
||||
attach_convo_id: bool = False,
|
||||
git_provider: ProviderType | None = None,
|
||||
conversation_id: str | None = None,
|
||||
mcp_config: MCPConfig | None = None,
|
||||
) -> AgentLoopInfo:
|
||||
logger.info(
|
||||
'Creating conversation',
|
||||
@@ -84,9 +82,6 @@ async def create_new_conversation(
|
||||
session_init_args['selected_branch'] = selected_branch
|
||||
session_init_args['git_provider'] = git_provider
|
||||
session_init_args['conversation_instructions'] = conversation_instructions
|
||||
if mcp_config:
|
||||
session_init_args['mcp_config'] = mcp_config
|
||||
|
||||
conversation_init_data = ConversationInitData(**session_init_args)
|
||||
|
||||
logger.info('Loading conversation store')
|
||||
|
||||
@@ -124,12 +124,10 @@ class Session:
|
||||
)
|
||||
|
||||
# Set Git user configuration if provided in settings
|
||||
git_user_name = getattr(settings, 'git_user_name', None)
|
||||
if git_user_name is not None:
|
||||
self.config.git_user_name = git_user_name
|
||||
git_user_email = getattr(settings, 'git_user_email', None)
|
||||
if git_user_email is not None:
|
||||
self.config.git_user_email = git_user_email
|
||||
if hasattr(settings, 'git_user_name') and settings.git_user_name:
|
||||
self.config.git_user_name = settings.git_user_name
|
||||
if hasattr(settings, 'git_user_email') and settings.git_user_email:
|
||||
self.config.git_user_email = settings.git_user_email
|
||||
max_iterations = settings.max_iterations or self.config.max_iterations
|
||||
|
||||
# Prioritize settings over config for max_budget_per_task
|
||||
@@ -154,14 +152,6 @@ class Session:
|
||||
self.logger.debug(
|
||||
f'MCP configuration before setup - self.config.mcp_config: {self.config.mcp}'
|
||||
)
|
||||
|
||||
# Check if settings has custom mcp_config
|
||||
mcp_config = getattr(settings, 'mcp_config', None)
|
||||
if mcp_config is not None:
|
||||
# Use the provided MCP SHTTP servers instead of default setup
|
||||
self.config.mcp = self.config.mcp.merge(mcp_config)
|
||||
self.logger.debug(f'Merged custom MCP Config: {mcp_config}')
|
||||
|
||||
# Add OpenHands' MCP server by default
|
||||
openhands_mcp_server, openhands_mcp_stdio_servers = (
|
||||
OpenHandsMCPConfigImpl.create_default_mcp_server_config(
|
||||
@@ -173,7 +163,7 @@ class Session:
|
||||
self.config.mcp.shttp_servers.append(openhands_mcp_server)
|
||||
self.logger.debug('Added default MCP HTTP server to config')
|
||||
|
||||
self.config.mcp.stdio_servers.extend(openhands_mcp_stdio_servers)
|
||||
self.config.mcp.stdio_servers.extend(openhands_mcp_stdio_servers)
|
||||
|
||||
self.logger.debug(
|
||||
f'MCP configuration after setup - self.config.mcp: {self.config.mcp}'
|
||||
|
||||
@@ -56,7 +56,6 @@ def get_supported_llm_models(config: OpenHandsConfig) -> list[str]:
|
||||
# Add OpenHands provider models
|
||||
openhands_models = [
|
||||
'openhands/claude-sonnet-4-20250514',
|
||||
'openhands/gpt-5-2025-08-07',
|
||||
'openhands/claude-opus-4-20250514',
|
||||
'openhands/gemini-2.5-pro',
|
||||
'openhands/o3',
|
||||
|
||||
@@ -10,7 +10,6 @@ class TermColor(Enum):
|
||||
SUCCESS = 'green'
|
||||
ERROR = 'red'
|
||||
INFO = 'blue'
|
||||
GREY = 'dark_grey'
|
||||
|
||||
|
||||
def colorize(text: str, color: TermColor = TermColor.WARNING) -> str:
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
# Performance Testing with Tool Calls
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the enhanced performance testing architecture that includes tool calls to better simulate real-world OpenHands usage patterns. Instead of simple prompt-response testing, we now test the complete tool interaction workflow.
|
||||
|
||||
## Why Tool Call Testing Matters
|
||||
|
||||
- **Real-world simulation**: OpenHands frequently uses tools (bash, file editing, etc.)
|
||||
- **Latency impact**: Tool calls add multiple round-trips and processing overhead
|
||||
- **Performance bottlenecks**: Tool parsing and execution can reveal different performance characteristics
|
||||
- **Complete workflow**: Tests the full LLM → Tool → LLM → Summary cycle
|
||||
|
||||
## Test Architecture
|
||||
|
||||
### 3-Step Tool Call Workflow
|
||||
|
||||
Each performance test now follows this standardized 3-step process:
|
||||
|
||||
#### Step 1: Initial Tool Request
|
||||
- **Prompt**: "What is the product of 45 and 126? Use the math tool to calculate this."
|
||||
- **Tool Definition**: Provide a `math` tool that can compute products
|
||||
- **Expected**: LLM should respond with a tool call to `math(a=45, b=126)`
|
||||
- **Measure**: Time to generate tool call response
|
||||
|
||||
#### Step 2: Tool Execution & Response
|
||||
- **Action**: Execute the math tool function (45 × 126 = 5670)
|
||||
- **Response**: Send tool result back to LLM as a tool message
|
||||
- **Expected**: LLM acknowledges the result
|
||||
- **Measure**: Time to process tool result
|
||||
|
||||
#### Step 3: Summary Request
|
||||
- **Prompt**: "Please summarize what just happened in our conversation."
|
||||
- **Expected**: LLM provides a summary of the math calculation
|
||||
- **Measure**: Time to generate summary response
|
||||
|
||||
### Message History Tracking
|
||||
|
||||
All messages and responses are preserved in a `messages` array:
|
||||
|
||||
```python
|
||||
messages = [
|
||||
{"role": "user", "content": "What is the product of 45 and 126? Use the math tool."},
|
||||
{"role": "assistant", "content": "", "tool_calls": [...]}, # Step 1 response
|
||||
{"role": "tool", "tool_call_id": "...", "content": "5670"}, # Step 2 tool result
|
||||
{"role": "assistant", "content": "The product is 5670."}, # Step 2 response
|
||||
{"role": "user", "content": "Please summarize what just happened."},
|
||||
{"role": "assistant", "content": "I calculated 45 × 126 = 5670..."} # Step 3 response
|
||||
]
|
||||
```
|
||||
|
||||
## Tool Definition
|
||||
|
||||
The `math` tool is defined consistently across all tests:
|
||||
|
||||
```python
|
||||
MATH_TOOL = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "math",
|
||||
"description": "Perform mathematical calculations",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"description": "The mathematical operation to perform",
|
||||
"enum": ["add", "subtract", "multiply", "divide"]
|
||||
},
|
||||
"a": {
|
||||
"type": "number",
|
||||
"description": "First number"
|
||||
},
|
||||
"b": {
|
||||
"type": "number",
|
||||
"description": "Second number"
|
||||
}
|
||||
},
|
||||
"required": ["operation", "a", "b"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
Each test measures:
|
||||
|
||||
- **Step 1 Duration**: Time to generate initial tool call
|
||||
- **Step 2 Duration**: Time to process tool result
|
||||
- **Step 3 Duration**: Time to generate summary
|
||||
- **Total Duration**: End-to-end workflow time
|
||||
- **Tool Call Accuracy**: Whether LLM correctly used the tool
|
||||
- **Response Quality**: Whether all steps completed successfully
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Environment Variables
|
||||
|
||||
All tests now use secure environment variable-based authentication:
|
||||
|
||||
- **LiteLLM Tests**: Use `LITELLM_PROXY_API_KEY` and `LITELLM_BASE_URL`
|
||||
- **Native API Tests**: Use `GEMINI_API_KEY` (for direct Google API calls)
|
||||
- **OpenHands Tests**: Use `LITELLM_PROXY_API_KEY` and `LITELLM_BASE_URL` (routed through LiteLLM)
|
||||
|
||||
### Credential Handling
|
||||
|
||||
- ✅ **Secure**: Read credentials from environment variables only
|
||||
- ✅ **No Hardcoding**: No API keys in source code or documentation
|
||||
- ✅ **Error Handling**: Graceful failure when credentials are missing
|
||||
- ✅ **Logging**: No credential values in logs or output
|
||||
|
||||
```python
|
||||
# Secure credential handling example
|
||||
api_key = os.getenv('LITELLM_PROXY_API_KEY')
|
||||
base_url = os.getenv('LITELLM_BASE_URL')
|
||||
|
||||
if not api_key:
|
||||
print('❌ LITELLM_PROXY_API_KEY environment variable not set')
|
||||
return
|
||||
|
||||
# Never log or print the actual key values
|
||||
print(f'✅ Using base URL: {base_url}') # OK to log URL
|
||||
print('✅ API key configured') # OK to confirm presence
|
||||
```
|
||||
|
||||
## Implementation Files
|
||||
|
||||
### Core Utility
|
||||
- `test_utils.py`: Shared tool call testing utilities
|
||||
|
||||
### Test Files
|
||||
- `test_thinking_budget.py`: Primary thinking/reasoning with tool calls
|
||||
- `test_litellm_comprehensive.py`: LiteLLM performance with tool calls
|
||||
- `test_native_gemini.py`: Native API baseline with tool calls
|
||||
- `test_openhands_gemini_fix.py`: OpenHands fix verification with tool calls
|
||||
- `run_performance_tests.py`: Orchestrator for all tool-based tests
|
||||
|
||||
## Expected Results
|
||||
|
||||
Tool call testing typically shows:
|
||||
|
||||
- **Higher Latency**: 2-3x longer than simple prompts due to multiple round-trips
|
||||
- **Reasoning Impact**: Thinking budget affects tool call generation speed
|
||||
- **Streaming Benefits**: Less pronounced due to structured tool responses
|
||||
- **Error Patterns**: Tool parsing failures reveal different bottlenecks
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Environment Setup
|
||||
```bash
|
||||
# Required for LiteLLM-based tests
|
||||
export LITELLM_PROXY_API_KEY="your-api-key-here"
|
||||
export LITELLM_BASE_URL="https://your-litellm-endpoint"
|
||||
|
||||
# Required for native Google API tests
|
||||
export GEMINI_API_KEY="your-google-api-key-here"
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
```bash
|
||||
# Run individual test with tool calls
|
||||
python test_thinking_budget.py
|
||||
|
||||
# Run comprehensive suite with tool calls
|
||||
python run_performance_tests.py
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
This architecture is based on:
|
||||
- OpenHands tool calling patterns (source: OpenHands codebase)
|
||||
- LiteLLM tool calling documentation (source: LiteLLM docs)
|
||||
- Google Gemini function calling API (source: Google AI documentation)
|
||||
- Security best practices for API key management (source: OWASP guidelines)
|
||||
Generated
+17
-4
@@ -3770,6 +3770,22 @@ http2 = ["h2 (>=3,<5)"]
|
||||
socks = ["socksio (==1.*)"]
|
||||
zstd = ["zstandard (>=0.18.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "httpx-aiohttp"
|
||||
version = "0.1.8"
|
||||
description = "Aiohttp transport for HTTPX"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "httpx_aiohttp-0.1.8-py3-none-any.whl", hash = "sha256:b7bd958d1331f3759a38a0ba22ad29832cb63ca69498c17735228055bf78fa7e"},
|
||||
{file = "httpx_aiohttp-0.1.8.tar.gz", hash = "sha256:756c5e74cdb568c3248ba63fe82bfe8bbe64b928728720f7eaac64b3cf46f308"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
aiohttp = ">=3.10.0,<4"
|
||||
httpx = ">=0.27.0"
|
||||
|
||||
[[package]]
|
||||
name = "httpx-sse"
|
||||
version = "0.4.0"
|
||||
@@ -5136,11 +5152,8 @@ files = [
|
||||
{file = "lxml-5.4.0-cp36-cp36m-win_amd64.whl", hash = "sha256:7ce1a171ec325192c6a636b64c94418e71a1964f56d002cc28122fceff0b6121"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:795f61bcaf8770e1b37eec24edf9771b307df3af74d1d6f27d812e15a9ff3872"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:29f451a4b614a7b5b6c2e043d7b64a15bd8304d7e767055e8ab68387a8cacf4e"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:891f7f991a68d20c75cb13c5c9142b2a3f9eb161f1f12a9489c82172d1f133c0"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4aa412a82e460571fad592d0f93ce9935a20090029ba08eca05c614f99b0cc92"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:ac7ba71f9561cd7d7b55e1ea5511543c0282e2b6450f122672a2694621d63b7e"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:c5d32f5284012deaccd37da1e2cd42f081feaa76981f0eaa474351b68df813c5"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:ce31158630a6ac85bddd6b830cffd46085ff90498b397bd0a259f59d27a12188"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:31e63621e073e04697c1b2d23fcb89991790eef370ec37ce4d5d469f40924ed6"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-win32.whl", hash = "sha256:be2ba4c3c5b7900246a8f866580700ef0d538f2ca32535e991027bdaba944063"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:09846782b1ef650b321484ad429217f5154da4d6e786636c38e434fa32e94e49"},
|
||||
@@ -11753,4 +11766,4 @@ third-party-runtimes = ["daytona", "e2b", "modal", "runloop-api-client"]
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12,<3.14"
|
||||
content-hash = "4640c66849d6436eed73826154e2d8cf88b456a4d1b71efb9438531245845826"
|
||||
content-hash = "8568c6ec2e11d4fcb23e206a24896b4d2d50e694c04011b668148f484e95b406"
|
||||
|
||||
@@ -20,6 +20,7 @@ packages = [
|
||||
]
|
||||
include = [
|
||||
"openhands/integrations/vscode/openhands-vscode-0.0.1.vsix",
|
||||
"microagents/**/*",
|
||||
]
|
||||
build = "build_vscode.py" # Build VSCode extension during Poetry build
|
||||
|
||||
@@ -41,6 +42,7 @@ numpy = "*"
|
||||
json-repair = "*"
|
||||
browsergym-core = "0.13.3" # integrate browsergym-core as the browsing interface
|
||||
html2text = "*"
|
||||
deprecated = "*"
|
||||
pexpect = "*"
|
||||
jinja2 = "^3.1.3"
|
||||
python-multipart = "*"
|
||||
@@ -97,6 +99,7 @@ e2b = { version = ">=1.0.5,<1.8.0", optional = true }
|
||||
modal = { version = ">=0.66.26,<1.2.0", optional = true }
|
||||
runloop-api-client = { version = "0.50.0", optional = true }
|
||||
daytona = { version = "0.24.2", optional = true }
|
||||
httpx-aiohttp = "^0.1.8"
|
||||
|
||||
[tool.poetry.extras]
|
||||
third_party_runtimes = [ "e2b", "modal", "runloop-api-client", "daytona" ]
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Comprehensive performance test runner with tool calls.
|
||||
|
||||
This script runs all performance tests using realistic tool call workflows
|
||||
and provides detailed comparison to identify performance characteristics.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
# Import shared utilities
|
||||
from test_utils import check_credentials
|
||||
|
||||
|
||||
def check_dependencies():
|
||||
"""Check if required dependencies are installed."""
|
||||
missing = []
|
||||
|
||||
try:
|
||||
import litellm # noqa: F401
|
||||
except ImportError:
|
||||
missing.append('litellm')
|
||||
|
||||
try:
|
||||
import google.generativeai # noqa: F401
|
||||
except ImportError:
|
||||
missing.append('google-generativeai')
|
||||
|
||||
try:
|
||||
import google.genai # noqa: F401
|
||||
except ImportError:
|
||||
missing.append('google-genai')
|
||||
|
||||
try:
|
||||
from openhands.core.config import LLMConfig # noqa: F401
|
||||
from openhands.llm.llm import LLM # noqa: F401
|
||||
except ImportError:
|
||||
print('⚠️ OpenHands modules not available - some tests will be skipped')
|
||||
|
||||
if missing:
|
||||
print('❌ Missing dependencies:')
|
||||
for dep in missing:
|
||||
print(f' - {dep}')
|
||||
print('\nInstall with:')
|
||||
for dep in missing:
|
||||
print(f' pip install {dep}')
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def run_all_tests():
|
||||
"""Run all performance tests and collect results."""
|
||||
print('🚀 Running All Performance Tests with Tool Calls')
|
||||
print('=' * 70)
|
||||
|
||||
all_results = []
|
||||
|
||||
# Import and run each test module
|
||||
test_modules = [
|
||||
('test_thinking_budget', 'Thinking Budget Tests'),
|
||||
('test_litellm_comprehensive', 'LiteLLM Comprehensive Tests'),
|
||||
('test_native_gemini', 'Native Gemini Tests'),
|
||||
('test_openhands_gemini_fix', 'OpenHands Gemini Fix Tests'),
|
||||
]
|
||||
|
||||
for module_name, description in test_modules:
|
||||
print(f'\n🧪 {description}')
|
||||
print('-' * 50)
|
||||
|
||||
try:
|
||||
# Import the module dynamically
|
||||
module = __import__(module_name)
|
||||
|
||||
# Get the test function based on module
|
||||
if hasattr(module, 'test_thinking_budget_configurations'):
|
||||
results = module.test_thinking_budget_configurations()
|
||||
elif hasattr(module, 'test_litellm_configurations'):
|
||||
results = module.test_litellm_configurations()
|
||||
elif hasattr(module, 'test_native_gemini_configurations'):
|
||||
results = module.test_native_gemini_configurations()
|
||||
elif hasattr(module, 'test_openhands_gemini_configurations'):
|
||||
results = module.test_openhands_gemini_configurations()
|
||||
else:
|
||||
print(f'⚠️ No test function found in {module_name}')
|
||||
continue
|
||||
|
||||
# Add module info to results
|
||||
for result in results:
|
||||
result['test_module'] = module_name
|
||||
result['test_description'] = description
|
||||
|
||||
all_results.extend(results)
|
||||
print(f'✅ Completed {len(results)} tests from {module_name}')
|
||||
|
||||
except ImportError as e:
|
||||
print(f'⚠️ Could not import {module_name}: {e}')
|
||||
except Exception as e:
|
||||
print(f'❌ Error running {module_name}: {e}')
|
||||
|
||||
return all_results
|
||||
|
||||
|
||||
def analyze_comprehensive_results(all_results: list[dict[str, Any]]):
|
||||
"""Analyze results from all test modules."""
|
||||
print('\n📊 COMPREHENSIVE PERFORMANCE ANALYSIS')
|
||||
print('=' * 70)
|
||||
|
||||
successful_results = [r for r in all_results if r.get('success', False)]
|
||||
|
||||
if not successful_results:
|
||||
print('❌ No successful tests to analyze')
|
||||
return
|
||||
|
||||
print(f'📈 Total Tests: {len(all_results)}')
|
||||
print(f'✅ Successful: {len(successful_results)}')
|
||||
print(f'❌ Failed: {len(all_results) - len(successful_results)}')
|
||||
|
||||
# Group by test module
|
||||
by_module = {}
|
||||
for result in successful_results:
|
||||
module = result.get('test_module', 'unknown')
|
||||
by_module.setdefault(module, []).append(result)
|
||||
|
||||
print('\n📋 Results by Test Module:')
|
||||
for module, results in by_module.items():
|
||||
avg_duration = sum(r.get('total_duration', 0) for r in results) / len(results)
|
||||
print(f' {module}: {len(results)} tests, avg {avg_duration:.3f}s')
|
||||
|
||||
# Overall performance ranking
|
||||
print('\n🏆 Overall Performance Ranking:')
|
||||
sorted_results = sorted(
|
||||
successful_results, key=lambda x: x.get('total_duration', float('inf'))
|
||||
)
|
||||
|
||||
for i, result in enumerate(sorted_results[:10], 1): # Top 10
|
||||
config_name = result.get('config_name', 'Unknown')
|
||||
duration = result.get('total_duration', 0)
|
||||
module = result.get('test_module', 'unknown')
|
||||
print(f' {i:2d}. {config_name} ({module}): {duration:.3f}s')
|
||||
|
||||
# Performance categories
|
||||
excellent = [r for r in successful_results if r.get('total_duration', 0) < 10]
|
||||
good = [r for r in successful_results if 10 <= r.get('total_duration', 0) < 20]
|
||||
slow = [r for r in successful_results if r.get('total_duration', 0) >= 20]
|
||||
|
||||
print('\n⚡ Performance Categories:')
|
||||
print(f' 🎉 Excellent (<10s): {len(excellent)} tests')
|
||||
print(f' 👍 Good (10-20s): {len(good)} tests')
|
||||
print(f' 🐌 Slow (≥20s): {len(slow)} tests')
|
||||
|
||||
# Tool call accuracy
|
||||
correct_results = sum(
|
||||
1 for r in successful_results if r.get('result_correct', False)
|
||||
)
|
||||
accuracy = (
|
||||
correct_results / len(successful_results) * 100 if successful_results else 0
|
||||
)
|
||||
print(
|
||||
f'\n🎯 Overall Tool Call Accuracy: {accuracy:.1f}% ({correct_results}/{len(successful_results)})'
|
||||
)
|
||||
|
||||
# API comparison
|
||||
litellm_results = [
|
||||
r for r in successful_results if 'litellm' in r.get('test_module', '').lower()
|
||||
]
|
||||
native_results = [
|
||||
r for r in successful_results if 'native' in r.get('test_module', '').lower()
|
||||
]
|
||||
openhands_results = [
|
||||
r for r in successful_results if 'openhands' in r.get('test_module', '').lower()
|
||||
]
|
||||
|
||||
if litellm_results and native_results:
|
||||
avg_litellm = sum(r.get('total_duration', 0) for r in litellm_results) / len(
|
||||
litellm_results
|
||||
)
|
||||
avg_native = sum(r.get('total_duration', 0) for r in native_results) / len(
|
||||
native_results
|
||||
)
|
||||
|
||||
print('\n🔄 API Comparison:')
|
||||
print(f' LiteLLM Average: {avg_litellm:.3f}s ({len(litellm_results)} tests)')
|
||||
print(f' Native API Average: {avg_native:.3f}s ({len(native_results)} tests)')
|
||||
|
||||
if avg_native > 0:
|
||||
advantage = (
|
||||
avg_litellm / avg_native
|
||||
if avg_native < avg_litellm
|
||||
else avg_native / avg_litellm
|
||||
)
|
||||
faster = 'Native API' if avg_native < avg_litellm else 'LiteLLM'
|
||||
print(f' {faster} is {advantage:.2f}x faster')
|
||||
|
||||
if openhands_results:
|
||||
avg_openhands = sum(
|
||||
r.get('total_duration', 0) for r in openhands_results
|
||||
) / len(openhands_results)
|
||||
print(
|
||||
f' OpenHands Average: {avg_openhands:.3f}s ({len(openhands_results)} tests)'
|
||||
)
|
||||
|
||||
# Save comprehensive results
|
||||
output_file = 'comprehensive_performance_results.json'
|
||||
with open(output_file, 'w') as f:
|
||||
json.dump(
|
||||
{
|
||||
'summary': {
|
||||
'total_tests': len(all_results),
|
||||
'successful_tests': len(successful_results),
|
||||
'failed_tests': len(all_results) - len(successful_results),
|
||||
'overall_accuracy': accuracy,
|
||||
},
|
||||
'results': all_results,
|
||||
'analysis': {
|
||||
'by_module': {
|
||||
module: len(results) for module, results in by_module.items()
|
||||
},
|
||||
'performance_categories': {
|
||||
'excellent': len(excellent),
|
||||
'good': len(good),
|
||||
'slow': len(slow),
|
||||
},
|
||||
},
|
||||
},
|
||||
f,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
print(f'\n💾 Comprehensive results saved to: {output_file}')
|
||||
|
||||
|
||||
def main():
|
||||
"""Run comprehensive performance tests with tool calls."""
|
||||
print('🚀 COMPREHENSIVE GEMINI PERFORMANCE INVESTIGATION WITH TOOL CALLS')
|
||||
print('=' * 70)
|
||||
print(
|
||||
'This comprehensive test suite uses realistic tool call workflows to evaluate:'
|
||||
)
|
||||
print('1. 🧠 Thinking Budget Configurations (optimized vs standard)')
|
||||
print('2. 🔄 LiteLLM Performance (various configurations)')
|
||||
print('3. 🎯 Native Google API Performance (baseline)')
|
||||
print('4. 🛠️ OpenHands Gemini Fix Verification (performance improvements)')
|
||||
print('5. 📊 Comparative Analysis (identify best configurations)')
|
||||
print()
|
||||
print('Each test uses a 3-step tool call workflow:')
|
||||
print(' Step 1: Ask LLM to calculate 45×126 using math tool')
|
||||
print(' Step 2: Execute tool (returns 5670) and send result back')
|
||||
print(' Step 3: Ask LLM to summarize the conversation')
|
||||
print()
|
||||
|
||||
# Check prerequisites
|
||||
if not check_dependencies():
|
||||
return 1
|
||||
|
||||
# Check credentials
|
||||
success, credentials = check_credentials()
|
||||
if not success:
|
||||
return 1
|
||||
|
||||
print('✅ All dependencies and credentials available')
|
||||
print()
|
||||
|
||||
# Run all tests
|
||||
all_results = run_all_tests()
|
||||
|
||||
if all_results:
|
||||
analyze_comprehensive_results(all_results)
|
||||
|
||||
print('\n💡 KEY INSIGHTS:')
|
||||
print(' Based on these tool call workflow results, you can determine:')
|
||||
print(' 1. Which API approach (LiteLLM vs Native) performs best with tools')
|
||||
print(
|
||||
' 2. Impact of reasoning effort and thinking budget on tool call performance'
|
||||
)
|
||||
print(' 3. Whether OpenHands optimizations improve real-world tool usage')
|
||||
print(' 4. Tool call accuracy across different configurations')
|
||||
print(' 5. Optimal configuration for production tool-enabled workflows')
|
||||
else:
|
||||
print('❌ No test results collected')
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,158 @@
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
|
||||
# PATCH LITELLM BEFORE ANY IMPORTS THAT MIGHT CACHE IT
|
||||
import litellm
|
||||
original_completion = litellm.completion
|
||||
|
||||
def debug_completion(*args, **kwargs):
|
||||
print("🔍 DEBUG_COMPLETION CALLED!")
|
||||
print(f"\n" + "="*80)
|
||||
print("ALL PARAMETERS SENT TO GEMINI API:")
|
||||
print("="*80)
|
||||
print(f"args: {args}")
|
||||
print(f"\nkwargs ({len(kwargs)} total):")
|
||||
for key, value in sorted(kwargs.items()):
|
||||
if key == 'messages':
|
||||
print(f" {key}: [{len(value)} messages]")
|
||||
for i, msg in enumerate(value):
|
||||
print(f" [{i}] {msg.get('role', 'unknown')}: {msg.get('content', '')[:50]}...")
|
||||
elif key == 'api_key':
|
||||
print(f" {key}: [REDACTED]")
|
||||
else:
|
||||
print(f" {key}: {value}")
|
||||
print("="*80)
|
||||
|
||||
# Call the original function
|
||||
return original_completion(*args, **kwargs)
|
||||
|
||||
# Patch immediately
|
||||
litellm.completion = debug_completion
|
||||
print(f"🔧 EARLY PATCH: litellm.completion = {litellm.completion}")
|
||||
|
||||
from openhands.core.config import LLMConfig
|
||||
from openhands.llm.llm import LLM
|
||||
|
||||
# Set dummy API key for testing
|
||||
os.environ['GOOGLE_API_KEY'] = 'test_api_key'
|
||||
# Enable debug mode to see parameters
|
||||
os.environ['DEBUG_LLM'] = 'true'
|
||||
|
||||
@pytest.fixture
|
||||
def llm_config():
|
||||
"""Fixture for LLMConfig - using gemini-pro-ah config (proxy)."""
|
||||
from openhands.core.config import get_llm_config_arg
|
||||
return get_llm_config_arg("gemini-pro-ah")
|
||||
|
||||
def test_gemini_api_call_parameters(llm_config):
|
||||
"""Test that the Gemini thinking patch is working and show the parameters being sent."""
|
||||
|
||||
try:
|
||||
# Initialize the LLM (debug patching already done at module level)
|
||||
llm = LLM(config=llm_config)
|
||||
|
||||
# Create a sample message
|
||||
messages = [{'role': 'user', 'content': 'Hello, world!'}]
|
||||
|
||||
# Call the completion method with thinking disabled
|
||||
print("Making LLM completion call with includeThoughts=False...")
|
||||
|
||||
# Override the generation config to disable thinking inclusion
|
||||
custom_kwargs = {
|
||||
'messages': messages,
|
||||
'generationConfig': {
|
||||
'temperature': 0,
|
||||
'topP': 1,
|
||||
'thinkingConfig': {'includeThoughts': False}
|
||||
}
|
||||
}
|
||||
|
||||
response = llm.completion(**custom_kwargs)
|
||||
|
||||
print(f"\nResponse received!")
|
||||
|
||||
# Let's see what the actual response content looks like
|
||||
print(f"\n🔍 RESPONSE ANALYSIS:")
|
||||
if hasattr(response, 'usage'):
|
||||
print(f"Input tokens: {response.usage.prompt_tokens} | Output tokens: {response.usage.completion_tokens}")
|
||||
|
||||
# Check what attributes the response has
|
||||
print(f"Response type: {type(response)}")
|
||||
print(f"Response attributes: {[attr for attr in dir(response) if not attr.startswith('_')]}")
|
||||
|
||||
# Check for any attributes that might contain raw data
|
||||
for attr in ['raw', '_raw_response', 'raw_response', 'original_response']:
|
||||
if hasattr(response, attr):
|
||||
value = getattr(response, attr)
|
||||
print(f"Found {attr}: {type(value)} - {value is not None}")
|
||||
|
||||
# Try to get the text content
|
||||
response_text = ""
|
||||
if hasattr(response, 'choices') and response.choices:
|
||||
if hasattr(response.choices[0], 'message') and hasattr(response.choices[0].message, 'content'):
|
||||
response_text = response.choices[0].message.content or ""
|
||||
|
||||
print(f"Response text length: {len(response_text)} characters")
|
||||
if response_text:
|
||||
print(f"First 200 chars: {response_text[:200]}...")
|
||||
print(f"Last 200 chars: ...{response_text[-200:]}")
|
||||
|
||||
# Check if we got thinking content in the raw response
|
||||
print(f"\n🔍 CHECKING FOR RAW RESPONSE:")
|
||||
print(f"Has 'raw' attribute: {hasattr(response, 'raw')}")
|
||||
if hasattr(response, 'raw'):
|
||||
print(f"Raw response type: {type(response.raw)}")
|
||||
print(f"Raw response is None: {response.raw is None}")
|
||||
|
||||
if hasattr(response, 'raw') and response.raw:
|
||||
raw_response = response.raw
|
||||
print(f"\n🔍 RAW RESPONSE STRUCTURE:")
|
||||
if 'candidates' in raw_response and raw_response['candidates']:
|
||||
candidate = raw_response['candidates'][0]
|
||||
if 'content' in candidate and 'parts' in candidate['content']:
|
||||
parts = candidate['content']['parts']
|
||||
print(f"Total parts in response: {len(parts)}")
|
||||
|
||||
for i, part in enumerate(parts):
|
||||
part_type = "thinking" if part.get('thought', False) else "regular"
|
||||
text_len = len(part.get('text', '')) if 'text' in part else 0
|
||||
print(f" Part {i}: {part_type}, {text_len} chars")
|
||||
if part.get('thought', False) and text_len > 0:
|
||||
print(f" Thinking preview: {part.get('text', '')[:100]}...")
|
||||
|
||||
thinking_parts = [part for part in parts if part.get('thought', False)]
|
||||
if thinking_parts:
|
||||
total_thinking_chars = sum(len(part.get('text', '')) for part in thinking_parts)
|
||||
print(f"✅ SUCCESS: Found {len(thinking_parts)} thinking part(s)! Total thinking chars: {total_thinking_chars}")
|
||||
else:
|
||||
print("❌ No thinking parts found in response")
|
||||
|
||||
# Check usage metadata for thinking tokens
|
||||
if 'usageMetadata' in raw_response:
|
||||
usage = raw_response['usageMetadata']
|
||||
thinking_tokens = usage.get('thoughtsTokenCount', 0)
|
||||
total_tokens = usage.get('totalTokenCount', 0)
|
||||
output_tokens = usage.get('candidatesTokenCount', 0)
|
||||
print(f"\n📊 TOKEN BREAKDOWN:")
|
||||
print(f" Total tokens: {total_tokens}")
|
||||
print(f" Output tokens: {output_tokens}")
|
||||
print(f" Thinking tokens: {thinking_tokens}")
|
||||
if thinking_tokens > 0:
|
||||
print(f"✅ Thinking represents {thinking_tokens}/{output_tokens} = {thinking_tokens/output_tokens*100:.1f}% of output")
|
||||
else:
|
||||
print("❌ No thinking tokens reported")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error during test: {e}")
|
||||
raise
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Run the test directly
|
||||
from openhands.core.config import get_llm_config_arg
|
||||
config = get_llm_config_arg("gemini-pro-ah")
|
||||
test_gemini_api_call_parameters(config)
|
||||
@@ -0,0 +1,298 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Comprehensive LiteLLM performance test for Gemini with tool calls.
|
||||
|
||||
This script tests LiteLLM performance with various configurations including:
|
||||
1. Different parameter combinations (streaming, temperature, etc.)
|
||||
2. OpenHands-style configuration and calls
|
||||
3. Reasoning effort and thinking budget parameters
|
||||
4. Tool call workflows for realistic testing
|
||||
|
||||
Uses secure credential handling with LITELLM_PROXY_API_KEY and LITELLM_BASE_URL.
|
||||
"""
|
||||
|
||||
import os
|
||||
from functools import partial
|
||||
|
||||
import litellm
|
||||
|
||||
# Import shared utilities
|
||||
from test_utils import (
|
||||
check_credentials,
|
||||
run_tool_call_test,
|
||||
)
|
||||
|
||||
|
||||
def create_litellm_completion_func(**config_params):
|
||||
"""Create LiteLLM completion function with secure credentials."""
|
||||
api_key = os.getenv('LITELLM_PROXY_API_KEY')
|
||||
base_url = os.getenv('LITELLM_BASE_URL')
|
||||
|
||||
if not api_key or not base_url:
|
||||
return None
|
||||
|
||||
def completion_func(messages, tools=None, **kwargs):
|
||||
params = {
|
||||
'model': 'litellm_proxy/gemini/gemini-2.5-pro',
|
||||
'messages': messages,
|
||||
'api_key': api_key,
|
||||
'base_url': base_url,
|
||||
'drop_params': True,
|
||||
**config_params, # Apply configuration parameters
|
||||
}
|
||||
|
||||
if tools:
|
||||
params['tools'] = tools
|
||||
|
||||
return litellm.completion(**params)
|
||||
|
||||
return completion_func
|
||||
|
||||
|
||||
def create_openhands_completion_func(**additional_params):
|
||||
"""Create completion function exactly like OpenHands does."""
|
||||
api_key = os.getenv('LITELLM_PROXY_API_KEY')
|
||||
base_url = os.getenv('LITELLM_BASE_URL')
|
||||
|
||||
if not api_key or not base_url:
|
||||
return None
|
||||
|
||||
# OpenHands default config
|
||||
config = {
|
||||
'model': 'litellm_proxy/gemini/gemini-2.5-pro',
|
||||
'api_key': api_key,
|
||||
'base_url': base_url,
|
||||
'api_version': None,
|
||||
'custom_llm_provider': None,
|
||||
'timeout': None,
|
||||
'drop_params': True,
|
||||
'seed': None,
|
||||
'temperature': 0.0,
|
||||
'top_p': 1.0,
|
||||
'top_k': None,
|
||||
'max_output_tokens': None,
|
||||
**additional_params, # Apply additional parameters
|
||||
}
|
||||
|
||||
completion_func = partial(
|
||||
litellm.completion,
|
||||
model=config['model'],
|
||||
api_key=config['api_key'],
|
||||
base_url=config['base_url'],
|
||||
api_version=config['api_version'],
|
||||
custom_llm_provider=config['custom_llm_provider'],
|
||||
timeout=config['timeout'],
|
||||
drop_params=config['drop_params'],
|
||||
seed=config['seed'],
|
||||
)
|
||||
|
||||
return completion_func
|
||||
|
||||
|
||||
def test_litellm_configurations():
|
||||
"""Test various LiteLLM configurations with tool calls."""
|
||||
print('🚀 Testing LiteLLM Configurations with Tool Calls')
|
||||
print('=' * 70)
|
||||
|
||||
# Check credentials
|
||||
success, credentials = check_credentials()
|
||||
if not success:
|
||||
return []
|
||||
|
||||
if not credentials['litellm_api_key'] or not credentials['litellm_base_url']:
|
||||
print('❌ LiteLLM credentials not available')
|
||||
return []
|
||||
|
||||
all_results = []
|
||||
|
||||
# Test configurations
|
||||
test_configs = [
|
||||
{
|
||||
'name': 'Basic LiteLLM',
|
||||
'func': create_litellm_completion_func(temperature=0.0),
|
||||
},
|
||||
{
|
||||
'name': 'LiteLLM with Streaming',
|
||||
'func': create_litellm_completion_func(temperature=0.0, stream=True),
|
||||
},
|
||||
{
|
||||
'name': 'OpenHands Style (No Stream)',
|
||||
'func': create_openhands_completion_func(),
|
||||
},
|
||||
{
|
||||
'name': 'OpenHands Style (Streaming)',
|
||||
'func': create_openhands_completion_func(stream=True),
|
||||
},
|
||||
{
|
||||
'name': 'Reasoning Effort: Low',
|
||||
'func': create_litellm_completion_func(reasoning_effort='low'),
|
||||
},
|
||||
{
|
||||
'name': 'Reasoning Effort: Medium',
|
||||
'func': create_litellm_completion_func(reasoning_effort='medium'),
|
||||
},
|
||||
{
|
||||
'name': 'Reasoning Effort: High',
|
||||
'func': create_litellm_completion_func(reasoning_effort='high'),
|
||||
},
|
||||
{
|
||||
'name': 'Thinking Budget: 128',
|
||||
'func': create_litellm_completion_func(thinking={'budget_tokens': 128}),
|
||||
},
|
||||
{
|
||||
'name': 'Thinking Budget: 1024',
|
||||
'func': create_litellm_completion_func(thinking={'budget_tokens': 1024}),
|
||||
},
|
||||
]
|
||||
|
||||
# Run tests
|
||||
for config in test_configs:
|
||||
if config['func'] is None:
|
||||
print(f'\n⏭️ Skipping {config["name"]} - not available')
|
||||
continue
|
||||
|
||||
print(f'\n🧪 Testing: {config["name"]}')
|
||||
print('-' * 50)
|
||||
|
||||
try:
|
||||
result = run_tool_call_test(config['func'], config['name'])
|
||||
result_dict = result.to_dict()
|
||||
result_dict['config_name'] = config['name']
|
||||
all_results.append(result_dict)
|
||||
|
||||
if result.success:
|
||||
print(f'✅ Success - Total: {result.total_duration:.3f}s')
|
||||
print(f' Step 1 (Tool Request): {result.step1_duration:.3f}s')
|
||||
print(f' Step 2 (Tool Response): {result.step2_duration:.3f}s')
|
||||
print(f' Step 3 (Summary): {result.step3_duration:.3f}s')
|
||||
print(f' Tool Result: {result.tool_call_result}')
|
||||
else:
|
||||
print(f'❌ Failed: {result.error}')
|
||||
|
||||
except Exception as e:
|
||||
print(f'❌ Test failed with exception: {e}')
|
||||
all_results.append(
|
||||
{
|
||||
'config_name': config['name'],
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'total_duration': 0,
|
||||
}
|
||||
)
|
||||
|
||||
return all_results
|
||||
|
||||
|
||||
def analyze_litellm_results(results):
|
||||
"""Analyze and compare LiteLLM test results."""
|
||||
print('\n📊 LITELLM PERFORMANCE ANALYSIS')
|
||||
print('=' * 70)
|
||||
|
||||
successful_results = [r for r in results if r['success']]
|
||||
|
||||
if not successful_results:
|
||||
print('❌ No successful tests to analyze')
|
||||
return
|
||||
|
||||
# Performance summary
|
||||
print('📈 Performance Summary:')
|
||||
sorted_results = sorted(successful_results, key=lambda x: x['total_duration'])
|
||||
for i, result in enumerate(sorted_results, 1):
|
||||
print(f' {i}. {result["config_name"]}: {result["total_duration"]:.3f}s')
|
||||
|
||||
# Group by configuration type
|
||||
[
|
||||
r
|
||||
for r in successful_results
|
||||
if 'Basic' in r['config_name'] or 'OpenHands Style' in r['config_name']
|
||||
]
|
||||
reasoning_results = [
|
||||
r for r in successful_results if 'Reasoning Effort' in r['config_name']
|
||||
]
|
||||
thinking_results = [
|
||||
r for r in successful_results if 'Thinking Budget' in r['config_name']
|
||||
]
|
||||
|
||||
# Analyze streaming vs non-streaming
|
||||
streaming_results = [
|
||||
r for r in successful_results if 'Streaming' in r['config_name']
|
||||
]
|
||||
non_streaming_results = [
|
||||
r for r in successful_results if 'Streaming' not in r['config_name']
|
||||
]
|
||||
|
||||
if streaming_results and non_streaming_results:
|
||||
avg_streaming = sum(r['total_duration'] for r in streaming_results) / len(
|
||||
streaming_results
|
||||
)
|
||||
avg_non_streaming = sum(
|
||||
r['total_duration'] for r in non_streaming_results
|
||||
) / len(non_streaming_results)
|
||||
|
||||
print('\n🌊 Streaming vs Non-Streaming:')
|
||||
print(f' Average Streaming: {avg_streaming:.3f}s')
|
||||
print(f' Average Non-Streaming: {avg_non_streaming:.3f}s')
|
||||
|
||||
if avg_non_streaming > 0:
|
||||
advantage = (
|
||||
avg_non_streaming / avg_streaming
|
||||
if avg_streaming < avg_non_streaming
|
||||
else avg_streaming / avg_non_streaming
|
||||
)
|
||||
faster = (
|
||||
'Streaming' if avg_streaming < avg_non_streaming else 'Non-Streaming'
|
||||
)
|
||||
print(f' {faster} is {advantage:.2f}x faster')
|
||||
|
||||
# Analyze reasoning effort impact
|
||||
if len(reasoning_results) > 1:
|
||||
print('\n🧠 Reasoning Effort Impact:')
|
||||
for result in sorted(reasoning_results, key=lambda x: x['total_duration']):
|
||||
effort = 'Unknown'
|
||||
if 'Low' in result['config_name']:
|
||||
effort = 'Low'
|
||||
elif 'Medium' in result['config_name']:
|
||||
effort = 'Medium'
|
||||
elif 'High' in result['config_name']:
|
||||
effort = 'High'
|
||||
print(f' {effort}: {result["total_duration"]:.3f}s')
|
||||
|
||||
# Analyze thinking budget impact
|
||||
if len(thinking_results) > 1:
|
||||
print('\n💭 Thinking Budget Impact:')
|
||||
for result in sorted(thinking_results, key=lambda x: x['total_duration']):
|
||||
budget = 'Unknown'
|
||||
if '128' in result['config_name']:
|
||||
budget = '128'
|
||||
elif '1024' in result['config_name']:
|
||||
budget = '1024'
|
||||
print(f' Budget {budget}: {result["total_duration"]:.3f}s')
|
||||
|
||||
# Tool call accuracy
|
||||
correct_results = sum(
|
||||
1 for r in successful_results if r.get('result_correct', False)
|
||||
)
|
||||
accuracy = correct_results / len(successful_results) * 100
|
||||
print(
|
||||
f'\n🎯 Tool Call Accuracy: {accuracy:.1f}% ({correct_results}/{len(successful_results)})'
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
"""Run comprehensive LiteLLM performance tests with tool calls."""
|
||||
print('🚀 COMPREHENSIVE LITELLM PERFORMANCE TEST WITH TOOL CALLS')
|
||||
print('=' * 70)
|
||||
print('This test evaluates LiteLLM performance using realistic tool call workflows')
|
||||
print('Uses secure credentials: LITELLM_PROXY_API_KEY and LITELLM_BASE_URL')
|
||||
print()
|
||||
|
||||
results = test_litellm_configurations()
|
||||
|
||||
if results:
|
||||
analyze_litellm_results(results)
|
||||
else:
|
||||
print('❌ No test results to analyze')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,211 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script using native Google Generative AI library with tool calls.
|
||||
|
||||
This provides a baseline for comparing native performance vs LiteLLM performance
|
||||
using realistic tool call workflows.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
try:
|
||||
import google.generativeai as genai
|
||||
|
||||
NATIVE_AVAILABLE = True
|
||||
except ImportError:
|
||||
NATIVE_AVAILABLE = False
|
||||
print(
|
||||
'⚠️ google-generativeai not installed. Install with: pip install google-generativeai'
|
||||
)
|
||||
|
||||
# Import shared utilities
|
||||
from test_utils import (
|
||||
MATH_TOOL,
|
||||
check_credentials,
|
||||
run_tool_call_test,
|
||||
)
|
||||
|
||||
|
||||
def create_native_gemini_completion_func(stream: bool = False):
|
||||
"""Create completion function using native Google Generative AI library."""
|
||||
if not NATIVE_AVAILABLE:
|
||||
return None
|
||||
|
||||
api_key = os.getenv('GEMINI_API_KEY')
|
||||
if not api_key:
|
||||
return None
|
||||
|
||||
genai.configure(api_key=api_key)
|
||||
model = genai.GenerativeModel('gemini-2.5-pro', tools=[MATH_TOOL])
|
||||
|
||||
def completion_func(messages, tools=None, **kwargs):
|
||||
# Convert messages to native API format
|
||||
if messages and messages[-1]['role'] == 'user':
|
||||
prompt = messages[-1]['content']
|
||||
return model.generate_content(
|
||||
prompt,
|
||||
generation_config=genai.types.GenerationConfig(
|
||||
temperature=0,
|
||||
max_output_tokens=8192,
|
||||
),
|
||||
stream=stream,
|
||||
)
|
||||
return None
|
||||
|
||||
return completion_func
|
||||
|
||||
|
||||
def test_native_gemini_configurations():
|
||||
"""Test various native Gemini configurations with tool calls."""
|
||||
print('🚀 Testing Native Gemini Configurations with Tool Calls')
|
||||
print('=' * 70)
|
||||
|
||||
# Check credentials
|
||||
success, credentials = check_credentials()
|
||||
if not success:
|
||||
return []
|
||||
|
||||
if not NATIVE_AVAILABLE:
|
||||
print('❌ google-generativeai not installed')
|
||||
return []
|
||||
|
||||
if not credentials['gemini_api_key']:
|
||||
print('❌ GEMINI_API_KEY not available')
|
||||
return []
|
||||
|
||||
all_results = []
|
||||
|
||||
# Test configurations
|
||||
test_configs = [
|
||||
{
|
||||
'name': 'Native Gemini (Non-Streaming)',
|
||||
'func': create_native_gemini_completion_func(stream=False),
|
||||
},
|
||||
{
|
||||
'name': 'Native Gemini (Streaming)',
|
||||
'func': create_native_gemini_completion_func(stream=True),
|
||||
},
|
||||
]
|
||||
|
||||
# Run tests
|
||||
for config in test_configs:
|
||||
if config['func'] is None:
|
||||
print(f'\n⏭️ Skipping {config["name"]} - not available')
|
||||
continue
|
||||
|
||||
print(f'\n🧪 Testing: {config["name"]}')
|
||||
print('-' * 50)
|
||||
|
||||
try:
|
||||
result = run_tool_call_test(config['func'], config['name'])
|
||||
result_dict = result.to_dict()
|
||||
result_dict['config_name'] = config['name']
|
||||
all_results.append(result_dict)
|
||||
|
||||
if result.success:
|
||||
print(f'✅ Success - Total: {result.total_duration:.3f}s')
|
||||
print(f' Step 1 (Tool Request): {result.step1_duration:.3f}s')
|
||||
print(f' Step 2 (Tool Response): {result.step2_duration:.3f}s')
|
||||
print(f' Step 3 (Summary): {result.step3_duration:.3f}s')
|
||||
print(f' Tool Result: {result.tool_call_result}')
|
||||
else:
|
||||
print(f'❌ Failed: {result.error}')
|
||||
|
||||
except Exception as e:
|
||||
print(f'❌ Test failed with exception: {e}')
|
||||
all_results.append(
|
||||
{
|
||||
'config_name': config['name'],
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'total_duration': 0,
|
||||
}
|
||||
)
|
||||
|
||||
return all_results
|
||||
|
||||
|
||||
def analyze_native_gemini_results(results):
|
||||
"""Analyze and compare native Gemini test results."""
|
||||
print('\n📊 NATIVE GEMINI PERFORMANCE ANALYSIS')
|
||||
print('=' * 70)
|
||||
|
||||
successful_results = [r for r in results if r['success']]
|
||||
|
||||
if not successful_results:
|
||||
print('❌ No successful tests to analyze')
|
||||
return
|
||||
|
||||
# Performance summary
|
||||
print('📈 Performance Summary:')
|
||||
sorted_results = sorted(successful_results, key=lambda x: x['total_duration'])
|
||||
for i, result in enumerate(sorted_results, 1):
|
||||
print(f' {i}. {result["config_name"]}: {result["total_duration"]:.3f}s')
|
||||
|
||||
# Analyze streaming vs non-streaming
|
||||
streaming_results = [
|
||||
r
|
||||
for r in successful_results
|
||||
if 'Streaming' in r['config_name'] and 'Non-Streaming' not in r['config_name']
|
||||
]
|
||||
non_streaming_results = [
|
||||
r for r in successful_results if 'Non-Streaming' in r['config_name']
|
||||
]
|
||||
|
||||
if streaming_results and non_streaming_results:
|
||||
avg_streaming = sum(r['total_duration'] for r in streaming_results) / len(
|
||||
streaming_results
|
||||
)
|
||||
avg_non_streaming = sum(
|
||||
r['total_duration'] for r in non_streaming_results
|
||||
) / len(non_streaming_results)
|
||||
|
||||
print('\n🌊 Streaming vs Non-Streaming:')
|
||||
print(f' Average Streaming: {avg_streaming:.3f}s')
|
||||
print(f' Average Non-Streaming: {avg_non_streaming:.3f}s')
|
||||
|
||||
if avg_non_streaming > 0:
|
||||
advantage = (
|
||||
avg_non_streaming / avg_streaming
|
||||
if avg_streaming < avg_non_streaming
|
||||
else avg_streaming / avg_non_streaming
|
||||
)
|
||||
faster = (
|
||||
'Streaming' if avg_streaming < avg_non_streaming else 'Non-Streaming'
|
||||
)
|
||||
print(f' {faster} is {advantage:.2f}x faster')
|
||||
|
||||
# Tool call accuracy
|
||||
correct_results = sum(
|
||||
1 for r in successful_results if r.get('result_correct', False)
|
||||
)
|
||||
accuracy = correct_results / len(successful_results) * 100
|
||||
print(
|
||||
f'\n🎯 Tool Call Accuracy: {accuracy:.1f}% ({correct_results}/{len(successful_results)})'
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
"""Run native Gemini performance tests with tool calls."""
|
||||
print('🚀 NATIVE GEMINI PERFORMANCE TEST WITH TOOL CALLS')
|
||||
print('=' * 70)
|
||||
print(
|
||||
'This test provides a baseline using native Google API with tool call workflows'
|
||||
)
|
||||
print()
|
||||
|
||||
if not NATIVE_AVAILABLE:
|
||||
print('❌ Cannot run native tests - google-generativeai not installed')
|
||||
print('Install with: pip install google-generativeai')
|
||||
return
|
||||
|
||||
results = test_native_gemini_configurations()
|
||||
|
||||
if results:
|
||||
analyze_native_gemini_results(results)
|
||||
else:
|
||||
print('❌ No test results to analyze')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,266 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test OpenHands Gemini performance fix with tool calls.
|
||||
|
||||
This script tests the optimized Gemini configuration in OpenHands
|
||||
that uses thinking={"budget_tokens": 128} instead of reasoning_effort,
|
||||
using realistic tool call workflows.
|
||||
|
||||
Based on performance investigation showing:
|
||||
- reasoning_effort='high' → ~25s (slow)
|
||||
- reasoning_effort='medium' → ~27s (slowest)
|
||||
- thinking={"budget_tokens": 128} → ~11s (fast, 2.4x speedup)
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from openhands.core.config import LLMConfig
|
||||
from openhands.llm.llm import LLM
|
||||
|
||||
# Import shared utilities
|
||||
from test_utils import (
|
||||
check_credentials,
|
||||
run_tool_call_test,
|
||||
)
|
||||
|
||||
|
||||
def create_openhands_llm_completion_func(
|
||||
reasoning_effort: str = None, use_litellm_proxy: bool = False
|
||||
):
|
||||
"""Create completion function using OpenHands LLM with secure credentials."""
|
||||
|
||||
if use_litellm_proxy:
|
||||
# Use LiteLLM proxy credentials
|
||||
api_key = os.getenv('LITELLM_PROXY_API_KEY')
|
||||
base_url = os.getenv('LITELLM_BASE_URL')
|
||||
|
||||
if not api_key or not base_url:
|
||||
return None
|
||||
|
||||
config = LLMConfig(
|
||||
model='litellm_proxy/gemini/gemini-2.5-pro',
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
max_output_tokens=1000,
|
||||
temperature=0.0,
|
||||
reasoning_effort=reasoning_effort,
|
||||
)
|
||||
else:
|
||||
# Use direct Gemini API
|
||||
api_key = os.getenv('GEMINI_API_KEY')
|
||||
|
||||
if not api_key:
|
||||
return None
|
||||
|
||||
config = LLMConfig(
|
||||
model='gemini-2.5-pro',
|
||||
api_key=api_key,
|
||||
max_output_tokens=1000,
|
||||
temperature=0.0,
|
||||
reasoning_effort=reasoning_effort,
|
||||
)
|
||||
|
||||
llm = LLM(config)
|
||||
|
||||
def completion_func(messages, tools=None, **kwargs):
|
||||
return llm.completion(messages=messages, tools=tools)
|
||||
|
||||
return completion_func
|
||||
|
||||
|
||||
def test_openhands_gemini_configurations():
|
||||
"""Test various OpenHands Gemini configurations with tool calls."""
|
||||
print('🚀 Testing OpenHands Gemini Configurations with Tool Calls')
|
||||
print('=' * 70)
|
||||
|
||||
# Check credentials
|
||||
success, credentials = check_credentials()
|
||||
if not success:
|
||||
return []
|
||||
|
||||
all_results = []
|
||||
|
||||
# Test configurations
|
||||
test_configs = [
|
||||
{
|
||||
'name': 'OpenHands Direct API (No Reasoning)',
|
||||
'func': create_openhands_llm_completion_func(),
|
||||
'available': credentials['gemini_api_key'] is not None,
|
||||
},
|
||||
{
|
||||
'name': 'OpenHands Direct API (High Reasoning)',
|
||||
'func': create_openhands_llm_completion_func(reasoning_effort='high'),
|
||||
'available': credentials['gemini_api_key'] is not None,
|
||||
},
|
||||
{
|
||||
'name': 'OpenHands via LiteLLM Proxy (No Reasoning)',
|
||||
'func': create_openhands_llm_completion_func(use_litellm_proxy=True),
|
||||
'available': credentials['litellm_api_key'] is not None
|
||||
and credentials['litellm_base_url'] is not None,
|
||||
},
|
||||
{
|
||||
'name': 'OpenHands via LiteLLM Proxy (High Reasoning)',
|
||||
'func': create_openhands_llm_completion_func(
|
||||
reasoning_effort='high', use_litellm_proxy=True
|
||||
),
|
||||
'available': credentials['litellm_api_key'] is not None
|
||||
and credentials['litellm_base_url'] is not None,
|
||||
},
|
||||
]
|
||||
|
||||
# Run tests
|
||||
for config in test_configs:
|
||||
if not config['available'] or config['func'] is None:
|
||||
print(f'\n⏭️ Skipping {config["name"]} - not available')
|
||||
continue
|
||||
|
||||
print(f'\n🧪 Testing: {config["name"]}')
|
||||
print('-' * 50)
|
||||
|
||||
try:
|
||||
result = run_tool_call_test(config['func'], config['name'])
|
||||
result_dict = result.to_dict()
|
||||
result_dict['config_name'] = config['name']
|
||||
all_results.append(result_dict)
|
||||
|
||||
if result.success:
|
||||
print(f'✅ Success - Total: {result.total_duration:.3f}s')
|
||||
print(f' Step 1 (Tool Request): {result.step1_duration:.3f}s')
|
||||
print(f' Step 2 (Tool Response): {result.step2_duration:.3f}s')
|
||||
print(f' Step 3 (Summary): {result.step3_duration:.3f}s')
|
||||
print(f' Tool Result: {result.tool_call_result}')
|
||||
|
||||
# Performance analysis
|
||||
if result.total_duration < 15:
|
||||
print(' 🎉 EXCELLENT: Fast performance!')
|
||||
elif result.total_duration < 25:
|
||||
print(' 👍 GOOD: Reasonable performance')
|
||||
else:
|
||||
print(' 🐌 SLOW: May need optimization')
|
||||
else:
|
||||
print(f'❌ Failed: {result.error}')
|
||||
|
||||
except Exception as e:
|
||||
print(f'❌ Test failed with exception: {e}')
|
||||
all_results.append(
|
||||
{
|
||||
'config_name': config['name'],
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'total_duration': 0,
|
||||
}
|
||||
)
|
||||
|
||||
return all_results
|
||||
|
||||
|
||||
def analyze_openhands_results(results):
|
||||
"""Analyze and compare OpenHands test results."""
|
||||
print('\n📊 OPENHANDS PERFORMANCE ANALYSIS')
|
||||
print('=' * 70)
|
||||
|
||||
successful_results = [r for r in results if r['success']]
|
||||
|
||||
if not successful_results:
|
||||
print('❌ No successful tests to analyze')
|
||||
return
|
||||
|
||||
# Performance summary
|
||||
print('📈 Performance Summary:')
|
||||
sorted_results = sorted(successful_results, key=lambda x: x['total_duration'])
|
||||
for i, result in enumerate(sorted_results, 1):
|
||||
print(f' {i}. {result["config_name"]}: {result["total_duration"]:.3f}s')
|
||||
|
||||
# Group by API type
|
||||
direct_results = [r for r in successful_results if 'Direct API' in r['config_name']]
|
||||
proxy_results = [
|
||||
r for r in successful_results if 'LiteLLM Proxy' in r['config_name']
|
||||
]
|
||||
|
||||
# Compare direct vs proxy
|
||||
if direct_results and proxy_results:
|
||||
avg_direct = sum(r['total_duration'] for r in direct_results) / len(
|
||||
direct_results
|
||||
)
|
||||
avg_proxy = sum(r['total_duration'] for r in proxy_results) / len(proxy_results)
|
||||
|
||||
print('\n🔄 Direct API vs LiteLLM Proxy:')
|
||||
print(f' Average Direct API: {avg_direct:.3f}s')
|
||||
print(f' Average LiteLLM Proxy: {avg_proxy:.3f}s')
|
||||
|
||||
if avg_direct > 0:
|
||||
advantage = (
|
||||
avg_direct / avg_proxy
|
||||
if avg_proxy < avg_direct
|
||||
else avg_proxy / avg_direct
|
||||
)
|
||||
faster = 'LiteLLM Proxy' if avg_proxy < avg_direct else 'Direct API'
|
||||
print(f' {faster} is {advantage:.2f}x faster')
|
||||
|
||||
# Analyze reasoning effort impact
|
||||
no_reasoning_results = [
|
||||
r for r in successful_results if 'No Reasoning' in r['config_name']
|
||||
]
|
||||
high_reasoning_results = [
|
||||
r for r in successful_results if 'High Reasoning' in r['config_name']
|
||||
]
|
||||
|
||||
if no_reasoning_results and high_reasoning_results:
|
||||
avg_no_reasoning = sum(r['total_duration'] for r in no_reasoning_results) / len(
|
||||
no_reasoning_results
|
||||
)
|
||||
avg_high_reasoning = sum(
|
||||
r['total_duration'] for r in high_reasoning_results
|
||||
) / len(high_reasoning_results)
|
||||
|
||||
print('\n🧠 Reasoning Effort Impact:')
|
||||
print(f' Average No Reasoning: {avg_no_reasoning:.3f}s')
|
||||
print(f' Average High Reasoning: {avg_high_reasoning:.3f}s')
|
||||
|
||||
if avg_no_reasoning > 0:
|
||||
overhead = avg_high_reasoning / avg_no_reasoning
|
||||
print(f' High Reasoning Overhead: {overhead:.2f}x')
|
||||
|
||||
# Performance fix verification
|
||||
fastest = min(successful_results, key=lambda x: x['total_duration'])
|
||||
print('\n🏆 Performance Fix Verification:')
|
||||
print(f' Fastest Configuration: {fastest["config_name"]}')
|
||||
print(f' Duration: {fastest["total_duration"]:.3f}s')
|
||||
|
||||
if fastest['total_duration'] < 15:
|
||||
print(' ✅ EXCELLENT: Performance fix is working!')
|
||||
elif fastest['total_duration'] < 25:
|
||||
print(' 👍 GOOD: Significant improvement achieved')
|
||||
else:
|
||||
print(' ⚠️ NEEDS WORK: Still slower than expected')
|
||||
|
||||
# Tool call accuracy
|
||||
correct_results = sum(
|
||||
1 for r in successful_results if r.get('result_correct', False)
|
||||
)
|
||||
accuracy = correct_results / len(successful_results) * 100
|
||||
print(
|
||||
f'\n🎯 Tool Call Accuracy: {accuracy:.1f}% ({correct_results}/{len(successful_results)})'
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
"""Run OpenHands Gemini performance tests with tool calls."""
|
||||
print('🚀 OPENHANDS GEMINI PERFORMANCE TEST WITH TOOL CALLS')
|
||||
print('=' * 70)
|
||||
print(
|
||||
'This test verifies the OpenHands Gemini performance fix using tool call workflows'
|
||||
)
|
||||
print('Expected: ~11s with thinking budget fix vs ~25s with reasoning_effort')
|
||||
print()
|
||||
|
||||
results = test_openhands_gemini_configurations()
|
||||
|
||||
if results:
|
||||
analyze_openhands_results(results)
|
||||
else:
|
||||
print('❌ No test results to analyze')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script to verify that our Gemini thinking patch works with OpenHands LLM module.
|
||||
This demonstrates the integration between our patch and the actual OpenHands code.
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini as gemini_module
|
||||
from litellm.llms.vertex_ai.gemini.transformation import sync_transform_request_body
|
||||
|
||||
|
||||
def apply_openhands_gemini_thinking_patch():
|
||||
"""
|
||||
Apply the thinking patch specifically for OpenHands usage.
|
||||
OpenHands uses sync litellm.completion(), so we need to patch the sync version.
|
||||
"""
|
||||
# Store the original function
|
||||
original_sync_transform = sync_transform_request_body
|
||||
|
||||
# Create patched sync version that adds thinkingConfig
|
||||
def patched_sync_transform_with_thinking(*args, **kwargs):
|
||||
# Add thinkingConfig to optional_params
|
||||
if 'optional_params' in kwargs:
|
||||
kwargs['optional_params']['thinkingConfig'] = {
|
||||
'includeThoughts': True,
|
||||
}
|
||||
return original_sync_transform(*args, **kwargs)
|
||||
|
||||
# Apply the patch
|
||||
gemini_module.sync_transform_request_body = patched_sync_transform_with_thinking
|
||||
|
||||
print('✅ OpenHands Gemini thinking patch applied!')
|
||||
return original_sync_transform
|
||||
|
||||
|
||||
def test_openhands_llm_integration():
|
||||
"""
|
||||
Test that our patch works with the OpenHands LLM module.
|
||||
"""
|
||||
print('🧪 Testing OpenHands LLM integration...')
|
||||
|
||||
# Apply our patch
|
||||
original_transform = apply_openhands_gemini_thinking_patch()
|
||||
|
||||
try:
|
||||
# Import the OpenHands LLM module and config
|
||||
from openhands.core.config import LLMConfig
|
||||
from openhands.llm.llm import LLM
|
||||
|
||||
# Mock the HTTP client to capture the request
|
||||
with patch(
|
||||
'litellm.llms.custom_httpx.http_handler.HTTPHandler.post'
|
||||
) as mock_post:
|
||||
# Configure the mock response
|
||||
mock_request = httpx.Request('POST', 'https://example.com')
|
||||
mock_response = httpx.Response(
|
||||
200,
|
||||
request=mock_request,
|
||||
json={
|
||||
'candidates': [
|
||||
{
|
||||
'content': {
|
||||
'parts': [{'text': 'Test response with thinking'}]
|
||||
}
|
||||
}
|
||||
],
|
||||
'usageMetadata': {
|
||||
'promptTokenCount': 10,
|
||||
'candidatesTokenCount': 5,
|
||||
'totalTokenCount': 15,
|
||||
},
|
||||
},
|
||||
)
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
# Create an LLM config for Gemini
|
||||
config = LLMConfig(model='gemini/gemini-pro', api_key='dummy-key')
|
||||
|
||||
# Create an LLM instance with Gemini
|
||||
llm = LLM(config=config)
|
||||
|
||||
# Make a completion call (this uses sync litellm.completion internally)
|
||||
try:
|
||||
llm.completion(
|
||||
messages=[{'role': 'user', 'content': 'Test message'}],
|
||||
temperature=0.7,
|
||||
)
|
||||
|
||||
# Verify the request was made
|
||||
if mock_post.called:
|
||||
# Get the final JSON payload
|
||||
args, kwargs = mock_post.call_args
|
||||
final_json_payload = kwargs.get('json', {})
|
||||
|
||||
# Check if thinkingConfig was included
|
||||
generation_config = final_json_payload.get('generationConfig', {})
|
||||
if 'thinkingConfig' in generation_config:
|
||||
print('✅ SUCCESS: thinkingConfig found in request payload!')
|
||||
print(
|
||||
f' thinkingConfig: {generation_config["thinkingConfig"]}'
|
||||
)
|
||||
return True
|
||||
else:
|
||||
print('❌ FAILURE: thinkingConfig not found in request payload')
|
||||
print(f' generationConfig: {generation_config}')
|
||||
return False
|
||||
else:
|
||||
print('❌ FAILURE: HTTP request was not made')
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f'⚠️ LLM call failed (expected with dummy key): {e}')
|
||||
# Even if the call fails due to auth, we can still check if the patch worked
|
||||
if mock_post.called:
|
||||
args, kwargs = mock_post.call_args
|
||||
final_json_payload = kwargs.get('json', {})
|
||||
generation_config = final_json_payload.get('generationConfig', {})
|
||||
if 'thinkingConfig' in generation_config:
|
||||
print('✅ SUCCESS: thinkingConfig found despite auth failure!')
|
||||
return True
|
||||
return False
|
||||
|
||||
except ImportError as e:
|
||||
print(f'⚠️ Could not import OpenHands LLM module: {e}')
|
||||
print(' This is expected if OpenHands modules are not available')
|
||||
return None
|
||||
|
||||
finally:
|
||||
# Restore original function
|
||||
gemini_module.sync_transform_request_body = original_transform
|
||||
print('✅ Patch removed, original function restored')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print('🚀 OpenHands Gemini Thinking Patch Integration Test')
|
||||
print('=' * 50)
|
||||
|
||||
result = test_openhands_llm_integration()
|
||||
|
||||
if result is True:
|
||||
print('\n🎉 Integration test PASSED!')
|
||||
print(' The patch successfully works with OpenHands LLM module')
|
||||
elif result is False:
|
||||
print('\n❌ Integration test FAILED!')
|
||||
print(' The patch did not work as expected')
|
||||
else:
|
||||
print('\n⚠️ Integration test SKIPPED!')
|
||||
print(' OpenHands modules not available for testing')
|
||||
|
||||
print('\n✨ Test completed!')
|
||||
@@ -0,0 +1,380 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test the impact of thinking budget on Gemini 2.5 Pro performance with tool calls.
|
||||
|
||||
This is the PRIMARY test for thinking/reasoning functionality, using the new
|
||||
3-step tool call architecture to better simulate real-world usage.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import google.generativeai as genai
|
||||
from google import genai as new_genai
|
||||
from google.genai import types
|
||||
|
||||
# Import shared utilities
|
||||
from test_utils import (
|
||||
check_credentials,
|
||||
run_tool_call_test,
|
||||
)
|
||||
|
||||
# Add LiteLLM import
|
||||
try:
|
||||
import litellm
|
||||
|
||||
LITELLM_AVAILABLE = True
|
||||
except ImportError:
|
||||
LITELLM_AVAILABLE = False
|
||||
print('⚠️ LiteLLM not available - skipping LiteLLM tests')
|
||||
|
||||
|
||||
def create_old_genai_completion_func():
|
||||
"""Create completion function using old google.generativeai API."""
|
||||
api_key = os.getenv('GEMINI_API_KEY')
|
||||
if not api_key:
|
||||
return None
|
||||
|
||||
# Google API compatible math tool (without 'type' field)
|
||||
google_math_tool = {
|
||||
'function_declarations': [
|
||||
{
|
||||
'name': 'math',
|
||||
'description': 'Perform mathematical calculations',
|
||||
'parameters': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'operation': {
|
||||
'type': 'string',
|
||||
'description': 'The mathematical operation to perform',
|
||||
'enum': ['add', 'subtract', 'multiply', 'divide'],
|
||||
},
|
||||
'a': {'type': 'number', 'description': 'First number'},
|
||||
'b': {'type': 'number', 'description': 'Second number'},
|
||||
},
|
||||
'required': ['operation', 'a', 'b'],
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
genai.configure(api_key=api_key)
|
||||
model = genai.GenerativeModel('gemini-2.5-pro', tools=[google_math_tool])
|
||||
|
||||
def completion_func(messages, **kwargs):
|
||||
# Convert messages to old API format
|
||||
if messages and messages[-1]['role'] == 'user':
|
||||
prompt = messages[-1]['content']
|
||||
return model.generate_content(prompt)
|
||||
return None
|
||||
|
||||
return completion_func
|
||||
|
||||
|
||||
def create_new_genai_completion_func(thinking_budget: int = None):
|
||||
"""Create completion function using new google.genai API with thinking budget."""
|
||||
api_key = os.getenv('GEMINI_API_KEY')
|
||||
if not api_key:
|
||||
return None
|
||||
|
||||
client = new_genai.Client(api_key=api_key)
|
||||
|
||||
config = {}
|
||||
if thinking_budget:
|
||||
config['thinking_config'] = types.ThinkingConfig(
|
||||
thinking_budget=thinking_budget
|
||||
)
|
||||
|
||||
def completion_func(messages, tools=None, **kwargs):
|
||||
# Convert to new API format
|
||||
contents = []
|
||||
for msg in messages:
|
||||
if msg['role'] == 'user':
|
||||
contents.append(
|
||||
types.Content(role='user', parts=[types.Part(text=msg['content'])])
|
||||
)
|
||||
elif msg['role'] == 'assistant':
|
||||
if 'tool_calls' in msg:
|
||||
# Handle tool calls
|
||||
parts = []
|
||||
for tool_call in msg['tool_calls']:
|
||||
# Parse arguments if they're JSON string (from LiteLLM format)
|
||||
args = tool_call['function']['arguments']
|
||||
if isinstance(args, str):
|
||||
args = json.loads(args)
|
||||
|
||||
parts.append(
|
||||
types.Part(
|
||||
function_call=types.FunctionCall(
|
||||
name=tool_call['function']['name'],
|
||||
args=args,
|
||||
)
|
||||
)
|
||||
)
|
||||
contents.append(types.Content(role='model', parts=parts))
|
||||
else:
|
||||
contents.append(
|
||||
types.Content(
|
||||
role='model', parts=[types.Part(text=msg['content'])]
|
||||
)
|
||||
)
|
||||
elif msg['role'] == 'tool':
|
||||
contents.append(
|
||||
types.Content(
|
||||
role='function',
|
||||
parts=[
|
||||
types.Part(
|
||||
function_response=types.FunctionResponse(
|
||||
name='math', response={'result': msg['content']}
|
||||
)
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
# Convert tools to new API format
|
||||
tool_configs = []
|
||||
if tools:
|
||||
for tool in tools:
|
||||
tool_configs.append(
|
||||
types.Tool(
|
||||
function_declarations=[
|
||||
types.FunctionDeclaration(
|
||||
name=tool['function']['name'],
|
||||
description=tool['function']['description'],
|
||||
parameters=tool['function']['parameters'],
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
# Add tools to config if available
|
||||
if tool_configs:
|
||||
config['tools'] = tool_configs
|
||||
|
||||
return client.models.generate_content(
|
||||
model='gemini-2.5-pro',
|
||||
contents=contents,
|
||||
config=types.GenerateContentConfig(**config) if config else None,
|
||||
)
|
||||
|
||||
return completion_func
|
||||
|
||||
|
||||
def create_litellm_completion_func(
|
||||
reasoning_effort: str = None, thinking_budget: int = None
|
||||
):
|
||||
"""Create completion function using LiteLLM with secure credentials."""
|
||||
if not LITELLM_AVAILABLE:
|
||||
return None
|
||||
|
||||
api_key = os.getenv('LITELLM_PROXY_API_KEY')
|
||||
base_url = os.getenv('LITELLM_BASE_URL')
|
||||
|
||||
if not api_key or not base_url:
|
||||
print('⚠️ LiteLLM credentials not available - skipping LiteLLM tests')
|
||||
return None
|
||||
|
||||
def completion_func(messages, tools=None, **kwargs):
|
||||
params = {
|
||||
'model': 'litellm_proxy/gemini/gemini-2.5-pro',
|
||||
'messages': messages,
|
||||
'api_key': api_key,
|
||||
'base_url': base_url,
|
||||
'drop_params': True,
|
||||
}
|
||||
|
||||
if tools:
|
||||
params['tools'] = tools
|
||||
|
||||
if reasoning_effort:
|
||||
params['reasoning_effort'] = reasoning_effort
|
||||
|
||||
if thinking_budget:
|
||||
params['thinking'] = {'budget_tokens': thinking_budget}
|
||||
|
||||
return litellm.completion(**params)
|
||||
|
||||
return completion_func
|
||||
|
||||
|
||||
def test_thinking_budget_configurations():
|
||||
"""Test various thinking budget configurations with tool calls."""
|
||||
print('🧠 Testing Thinking Budget Configurations with Tool Calls')
|
||||
print('=' * 70)
|
||||
|
||||
# Check credentials
|
||||
success, credentials = check_credentials()
|
||||
if not success:
|
||||
return
|
||||
|
||||
all_results = []
|
||||
|
||||
# Test configurations
|
||||
test_configs = [
|
||||
{
|
||||
'name': 'Old API (No Thinking)',
|
||||
'func': create_old_genai_completion_func(),
|
||||
'available': credentials['gemini_api_key'] is not None,
|
||||
},
|
||||
{
|
||||
'name': 'New API - Thinking Budget: 128',
|
||||
'func': create_new_genai_completion_func(thinking_budget=128),
|
||||
'available': credentials['gemini_api_key'] is not None,
|
||||
},
|
||||
{
|
||||
'name': 'New API - Thinking Budget: 1024',
|
||||
'func': create_new_genai_completion_func(thinking_budget=1024),
|
||||
'available': credentials['gemini_api_key'] is not None,
|
||||
},
|
||||
{
|
||||
'name': 'New API - Thinking Budget: 4096',
|
||||
'func': create_new_genai_completion_func(thinking_budget=4096),
|
||||
'available': credentials['gemini_api_key'] is not None,
|
||||
},
|
||||
{
|
||||
'name': 'LiteLLM - Reasoning Effort: Low',
|
||||
'func': create_litellm_completion_func(reasoning_effort='low'),
|
||||
'available': LITELLM_AVAILABLE
|
||||
and credentials['litellm_api_key'] is not None,
|
||||
},
|
||||
{
|
||||
'name': 'LiteLLM - Reasoning Effort: High',
|
||||
'func': create_litellm_completion_func(reasoning_effort='high'),
|
||||
'available': LITELLM_AVAILABLE
|
||||
and credentials['litellm_api_key'] is not None,
|
||||
},
|
||||
{
|
||||
'name': 'LiteLLM - Thinking Budget: 128',
|
||||
'func': create_litellm_completion_func(thinking_budget=128),
|
||||
'available': LITELLM_AVAILABLE
|
||||
and credentials['litellm_api_key'] is not None,
|
||||
},
|
||||
]
|
||||
|
||||
# Run tests
|
||||
for config in test_configs:
|
||||
if not config['available'] or config['func'] is None:
|
||||
print(f'\n⏭️ Skipping {config["name"]} - not available')
|
||||
continue
|
||||
|
||||
print(f'\n🧪 Testing: {config["name"]}')
|
||||
print('-' * 50)
|
||||
|
||||
try:
|
||||
result = run_tool_call_test(config['func'], config['name'])
|
||||
result_dict = result.to_dict()
|
||||
result_dict['config_name'] = config['name']
|
||||
all_results.append(result_dict)
|
||||
|
||||
if result.success:
|
||||
print(f'✅ Success - Total: {result.total_duration:.3f}s')
|
||||
print(f' Step 1 (Tool Request): {result.step1_duration:.3f}s')
|
||||
print(f' Step 2 (Tool Response): {result.step2_duration:.3f}s')
|
||||
print(f' Step 3 (Summary): {result.step3_duration:.3f}s')
|
||||
print(f' Tool Result: {result.tool_call_result}')
|
||||
else:
|
||||
print(f'❌ Failed: {result.error}')
|
||||
|
||||
except Exception as e:
|
||||
print(f'❌ Test failed with exception: {e}')
|
||||
all_results.append(
|
||||
{
|
||||
'config_name': config['name'],
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'total_duration': 0,
|
||||
}
|
||||
)
|
||||
|
||||
return all_results
|
||||
|
||||
|
||||
def analyze_thinking_budget_results(results):
|
||||
"""Analyze and compare thinking budget test results."""
|
||||
print('\n📊 THINKING BUDGET ANALYSIS')
|
||||
print('=' * 70)
|
||||
|
||||
successful_results = [r for r in results if r['success']]
|
||||
|
||||
if not successful_results:
|
||||
print('❌ No successful tests to analyze')
|
||||
return
|
||||
|
||||
# Group by API type
|
||||
old_api_results = [r for r in successful_results if 'Old API' in r['config_name']]
|
||||
new_api_results = [r for r in successful_results if 'New API' in r['config_name']]
|
||||
[r for r in successful_results if 'LiteLLM' in r['config_name']]
|
||||
|
||||
print('📈 Performance Summary:')
|
||||
|
||||
# Show all results sorted by speed
|
||||
sorted_results = sorted(successful_results, key=lambda x: x['total_duration'])
|
||||
for i, result in enumerate(sorted_results, 1):
|
||||
print(f' {i}. {result["config_name"]}: {result["total_duration"]:.3f}s')
|
||||
|
||||
# Compare API types
|
||||
if old_api_results and new_api_results:
|
||||
old_avg = sum(r['total_duration'] for r in old_api_results) / len(
|
||||
old_api_results
|
||||
)
|
||||
new_avg = sum(r['total_duration'] for r in new_api_results) / len(
|
||||
new_api_results
|
||||
)
|
||||
|
||||
print('\n🔄 API Comparison:')
|
||||
print(f' Old API Average: {old_avg:.3f}s')
|
||||
print(f' New API Average: {new_avg:.3f}s')
|
||||
|
||||
if old_avg > 0:
|
||||
improvement = old_avg / new_avg if new_avg < old_avg else new_avg / old_avg
|
||||
direction = 'faster' if new_avg < old_avg else 'slower'
|
||||
print(f' New API is {improvement:.2f}x {direction}')
|
||||
|
||||
# Analyze thinking budget impact
|
||||
thinking_budget_results = [
|
||||
r for r in new_api_results if 'Thinking Budget' in r['config_name']
|
||||
]
|
||||
if len(thinking_budget_results) > 1:
|
||||
print('\n🧠 Thinking Budget Impact:')
|
||||
for result in sorted(
|
||||
thinking_budget_results, key=lambda x: x['total_duration']
|
||||
):
|
||||
budget = 'Unknown'
|
||||
if '128' in result['config_name']:
|
||||
budget = '128'
|
||||
elif '1024' in result['config_name']:
|
||||
budget = '1024'
|
||||
elif '4096' in result['config_name']:
|
||||
budget = '4096'
|
||||
print(f' Budget {budget}: {result["total_duration"]:.3f}s')
|
||||
|
||||
# Tool call accuracy
|
||||
correct_results = sum(
|
||||
1 for r in successful_results if r.get('result_correct', False)
|
||||
)
|
||||
accuracy = correct_results / len(successful_results) * 100
|
||||
print(
|
||||
f'\n🎯 Tool Call Accuracy: {accuracy:.1f}% ({correct_results}/{len(successful_results)})'
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
"""Run thinking budget performance tests with tool calls."""
|
||||
print('🚀 THINKING BUDGET PERFORMANCE TEST WITH TOOL CALLS')
|
||||
print('=' * 70)
|
||||
print(
|
||||
'This test evaluates thinking budget impact using realistic tool call workflows'
|
||||
)
|
||||
print()
|
||||
|
||||
results = test_thinking_budget_configurations()
|
||||
|
||||
if results:
|
||||
analyze_thinking_budget_results(results)
|
||||
else:
|
||||
print('❌ No test results to analyze')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
+384
@@ -0,0 +1,384 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared utilities for performance testing with tool calls.
|
||||
|
||||
This module provides common functionality for testing LLM performance
|
||||
with tool interactions, following the 3-step workflow:
|
||||
1. Initial tool request
|
||||
2. Tool execution and response
|
||||
3. Summary request
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
# Standard math tool definition used across all tests
|
||||
MATH_TOOL = {
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': 'math',
|
||||
'description': 'Perform mathematical calculations',
|
||||
'parameters': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'operation': {
|
||||
'type': 'string',
|
||||
'description': 'The mathematical operation to perform',
|
||||
'enum': ['add', 'subtract', 'multiply', 'divide'],
|
||||
},
|
||||
'a': {'type': 'number', 'description': 'First number'},
|
||||
'b': {'type': 'number', 'description': 'Second number'},
|
||||
},
|
||||
'required': ['operation', 'a', 'b'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Test prompts for the 3-step workflow
|
||||
STEP1_PROMPT = 'What is the product of 45 and 126? Use the math tool to calculate this.'
|
||||
STEP3_PROMPT = 'Please summarize what just happened in our conversation.'
|
||||
|
||||
|
||||
def execute_math_tool(operation: str, a: float, b: float) -> str:
|
||||
"""Execute the math tool function."""
|
||||
if operation == 'multiply':
|
||||
result = a * b
|
||||
elif operation == 'add':
|
||||
result = a + b
|
||||
elif operation == 'subtract':
|
||||
result = a - b
|
||||
elif operation == 'divide':
|
||||
if b == 0:
|
||||
return 'Error: Division by zero'
|
||||
result = a / b
|
||||
else:
|
||||
return f"Error: Unknown operation '{operation}'"
|
||||
|
||||
return str(result)
|
||||
|
||||
|
||||
def check_credentials() -> tuple[bool, dict[str, Optional[str]]]:
|
||||
"""
|
||||
Check for required environment variables.
|
||||
|
||||
Returns:
|
||||
Tuple of (success, credentials_dict)
|
||||
"""
|
||||
credentials = {
|
||||
'litellm_api_key': os.getenv('LITELLM_PROXY_API_KEY'),
|
||||
'litellm_base_url': os.getenv('LITELLM_BASE_URL'),
|
||||
'gemini_api_key': os.getenv('GEMINI_API_KEY'),
|
||||
}
|
||||
|
||||
# At least one set of credentials should be available
|
||||
has_litellm = credentials['litellm_api_key'] and credentials['litellm_base_url']
|
||||
has_gemini = credentials['gemini_api_key']
|
||||
|
||||
if not (has_litellm or has_gemini):
|
||||
print('❌ No valid credentials found')
|
||||
print(' For LiteLLM: Set LITELLM_PROXY_API_KEY and LITELLM_BASE_URL')
|
||||
print(' For Gemini: Set GEMINI_API_KEY')
|
||||
return False, credentials
|
||||
|
||||
# Log what we have (without exposing keys)
|
||||
if has_litellm:
|
||||
print(
|
||||
f'✅ LiteLLM credentials configured (base_url: {credentials["litellm_base_url"]})'
|
||||
)
|
||||
if has_gemini:
|
||||
print('✅ Gemini API key configured')
|
||||
|
||||
return True, credentials
|
||||
|
||||
|
||||
def extract_tool_call(response: Any) -> Optional[dict[str, Any]]:
|
||||
"""
|
||||
Extract tool call information from LLM response.
|
||||
|
||||
Works with both LiteLLM and native API responses.
|
||||
"""
|
||||
try:
|
||||
# Handle LiteLLM streaming response format
|
||||
if hasattr(response, '__iter__') and not isinstance(response, (str, bytes)):
|
||||
# Collect streaming chunks to find tool calls
|
||||
for chunk in response:
|
||||
if hasattr(chunk, 'choices') and chunk.choices:
|
||||
choice = chunk.choices[0]
|
||||
if hasattr(choice, 'delta') and hasattr(choice.delta, 'tool_calls'):
|
||||
tool_calls = choice.delta.tool_calls
|
||||
if tool_calls and len(tool_calls) > 0:
|
||||
tool_call = tool_calls[0]
|
||||
return {
|
||||
'id': tool_call.id,
|
||||
'name': tool_call.function.name,
|
||||
'arguments': json.loads(tool_call.function.arguments),
|
||||
}
|
||||
|
||||
# Handle LiteLLM response format
|
||||
if hasattr(response, 'choices') and response.choices:
|
||||
choice = response.choices[0]
|
||||
if hasattr(choice, 'message') and hasattr(choice.message, 'tool_calls'):
|
||||
tool_calls = choice.message.tool_calls
|
||||
if tool_calls and len(tool_calls) > 0:
|
||||
tool_call = tool_calls[0]
|
||||
return {
|
||||
'id': tool_call.id,
|
||||
'name': tool_call.function.name,
|
||||
'arguments': json.loads(tool_call.function.arguments),
|
||||
}
|
||||
|
||||
# Handle native Google API response format
|
||||
if hasattr(response, 'candidates') and response.candidates:
|
||||
candidate = response.candidates[0]
|
||||
if hasattr(candidate, 'content') and hasattr(candidate.content, 'parts'):
|
||||
for part in candidate.content.parts:
|
||||
if hasattr(part, 'function_call'):
|
||||
func_call = part.function_call
|
||||
return {
|
||||
'id': f'call_{int(time.time())}', # Generate ID for native API
|
||||
'name': func_call.name,
|
||||
'arguments': dict(func_call.args),
|
||||
}
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f'⚠️ Error extracting tool call: {e}')
|
||||
return None
|
||||
|
||||
|
||||
def create_tool_response_message(tool_call_id: str, result: str) -> dict[str, Any]:
|
||||
"""Create a tool response message for the conversation."""
|
||||
return {'role': 'tool', 'tool_call_id': tool_call_id, 'content': result}
|
||||
|
||||
|
||||
class ToolCallTestResult:
|
||||
"""Container for tool call test results."""
|
||||
|
||||
def __init__(self):
|
||||
self.success = False
|
||||
self.error = None
|
||||
self.messages: list[dict[str, Any]] = []
|
||||
|
||||
# Timing metrics
|
||||
self.step1_duration = 0.0 # Initial tool request
|
||||
self.step2_duration = 0.0 # Tool execution response
|
||||
self.step3_duration = 0.0 # Summary generation
|
||||
self.total_duration = 0.0
|
||||
|
||||
# Tool call metrics
|
||||
self.tool_call_success = False
|
||||
self.tool_call_result = None
|
||||
self.expected_result = '5670' # 45 * 126
|
||||
|
||||
# Response metrics
|
||||
self.step1_response_length = 0
|
||||
self.step2_response_length = 0
|
||||
self.step3_response_length = 0
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert result to dictionary for analysis."""
|
||||
return {
|
||||
'success': self.success,
|
||||
'error': self.error,
|
||||
'step1_duration': self.step1_duration,
|
||||
'step2_duration': self.step2_duration,
|
||||
'step3_duration': self.step3_duration,
|
||||
'total_duration': self.total_duration,
|
||||
'tool_call_success': self.tool_call_success,
|
||||
'tool_call_result': self.tool_call_result,
|
||||
'result_correct': self.tool_call_result == self.expected_result,
|
||||
'step1_response_length': self.step1_response_length,
|
||||
'step2_response_length': self.step2_response_length,
|
||||
'step3_response_length': self.step3_response_length,
|
||||
'message_count': len(self.messages),
|
||||
}
|
||||
|
||||
|
||||
def run_tool_call_test(
|
||||
completion_func, model_name: str, **kwargs
|
||||
) -> ToolCallTestResult:
|
||||
"""
|
||||
Run the standardized 3-step tool call test.
|
||||
|
||||
Args:
|
||||
completion_func: Function to call for LLM completions
|
||||
model_name: Name of the model being tested
|
||||
**kwargs: Additional parameters for the completion function
|
||||
|
||||
Returns:
|
||||
ToolCallTestResult with timing and success metrics
|
||||
"""
|
||||
result = ToolCallTestResult()
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# Step 1: Initial tool request
|
||||
print('🔧 Step 1: Requesting tool call...')
|
||||
step1_start = time.time()
|
||||
|
||||
result.messages = [{'role': 'user', 'content': STEP1_PROMPT}]
|
||||
|
||||
step1_response = completion_func(
|
||||
messages=result.messages, tools=[MATH_TOOL], **kwargs
|
||||
)
|
||||
|
||||
result.step1_duration = time.time() - step1_start
|
||||
|
||||
# Extract tool call from response
|
||||
tool_call = extract_tool_call(step1_response)
|
||||
if not tool_call:
|
||||
result.error = 'No tool call found in Step 1 response'
|
||||
return result
|
||||
|
||||
result.tool_call_success = True
|
||||
print(f'✅ Tool call extracted: {tool_call["name"]}({tool_call["arguments"]})')
|
||||
|
||||
# Add assistant response to messages
|
||||
result.messages.append(
|
||||
{
|
||||
'role': 'assistant',
|
||||
'content': '',
|
||||
'tool_calls': [
|
||||
{
|
||||
'id': tool_call['id'],
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': tool_call['name'],
|
||||
'arguments': json.dumps(tool_call['arguments']),
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
# Step 2: Execute tool and send result
|
||||
print('🔧 Step 2: Executing tool and sending result...')
|
||||
step2_start = time.time()
|
||||
|
||||
# Execute the math tool
|
||||
args = tool_call['arguments']
|
||||
tool_result = execute_math_tool(
|
||||
args.get('operation', 'multiply'), args.get('a', 45), args.get('b', 126)
|
||||
)
|
||||
result.tool_call_result = tool_result
|
||||
print(f'✅ Tool result: {tool_result}')
|
||||
|
||||
# Add tool response to messages
|
||||
result.messages.append(
|
||||
create_tool_response_message(tool_call['id'], tool_result)
|
||||
)
|
||||
|
||||
# Get LLM response to tool result
|
||||
step2_response = completion_func(messages=result.messages, **kwargs)
|
||||
|
||||
result.step2_duration = time.time() - step2_start
|
||||
|
||||
# Extract content from step 2 response
|
||||
step2_content = ''
|
||||
if hasattr(step2_response, 'choices') and step2_response.choices:
|
||||
step2_content = step2_response.choices[0].message.content or ''
|
||||
elif hasattr(step2_response, 'candidates') and step2_response.candidates:
|
||||
step2_content = step2_response.candidates[0].content.parts[0].text or ''
|
||||
|
||||
result.step2_response_length = len(step2_content)
|
||||
result.messages.append({'role': 'assistant', 'content': step2_content})
|
||||
|
||||
# Step 3: Request summary
|
||||
print('🔧 Step 3: Requesting summary...')
|
||||
step3_start = time.time()
|
||||
|
||||
result.messages.append({'role': 'user', 'content': STEP3_PROMPT})
|
||||
|
||||
step3_response = completion_func(messages=result.messages, **kwargs)
|
||||
|
||||
result.step3_duration = time.time() - step3_start
|
||||
|
||||
# Extract content from step 3 response
|
||||
step3_content = ''
|
||||
if hasattr(step3_response, 'choices') and step3_response.choices:
|
||||
step3_content = step3_response.choices[0].message.content or ''
|
||||
elif hasattr(step3_response, 'candidates') and step3_response.candidates:
|
||||
step3_content = step3_response.candidates[0].content.parts[0].text or ''
|
||||
|
||||
result.step3_response_length = len(step3_content)
|
||||
result.messages.append({'role': 'assistant', 'content': step3_content})
|
||||
|
||||
result.success = True
|
||||
print('✅ All steps completed successfully')
|
||||
|
||||
except Exception as e:
|
||||
result.error = str(e)
|
||||
print(f'❌ Test failed: {e}')
|
||||
|
||||
result.total_duration = time.time() - start_time
|
||||
return result
|
||||
|
||||
|
||||
def print_tool_call_results(results: list[ToolCallTestResult], test_name: str):
|
||||
"""Print formatted results for tool call tests."""
|
||||
print(f'\n📊 {test_name} - Tool Call Test Results')
|
||||
print('=' * 60)
|
||||
|
||||
successful_results = [r for r in results if r.success]
|
||||
|
||||
if not successful_results:
|
||||
print('❌ No successful tests to analyze')
|
||||
return
|
||||
|
||||
# Summary statistics
|
||||
total_tests = len(results)
|
||||
success_rate = len(successful_results) / total_tests * 100
|
||||
|
||||
print(
|
||||
f'Success Rate: {success_rate:.1f}% ({len(successful_results)}/{total_tests})'
|
||||
)
|
||||
|
||||
# Timing analysis
|
||||
avg_total = sum(r.total_duration for r in successful_results) / len(
|
||||
successful_results
|
||||
)
|
||||
avg_step1 = sum(r.step1_duration for r in successful_results) / len(
|
||||
successful_results
|
||||
)
|
||||
avg_step2 = sum(r.step2_duration for r in successful_results) / len(
|
||||
successful_results
|
||||
)
|
||||
avg_step3 = sum(r.step3_duration for r in successful_results) / len(
|
||||
successful_results
|
||||
)
|
||||
|
||||
print('\nTiming Analysis:')
|
||||
print(f' Average Total Duration: {avg_total:.3f}s')
|
||||
print(f' Average Step 1 (Tool Request): {avg_step1:.3f}s')
|
||||
print(f' Average Step 2 (Tool Response): {avg_step2:.3f}s')
|
||||
print(f' Average Step 3 (Summary): {avg_step3:.3f}s')
|
||||
|
||||
# Tool call accuracy
|
||||
tool_success_rate = (
|
||||
sum(1 for r in successful_results if r.tool_call_success)
|
||||
/ len(successful_results)
|
||||
* 100
|
||||
)
|
||||
correct_results = (
|
||||
sum(1 for r in successful_results if r.tool_call_result == '5670')
|
||||
/ len(successful_results)
|
||||
* 100
|
||||
)
|
||||
|
||||
print('\nTool Call Analysis:')
|
||||
print(f' Tool Call Success Rate: {tool_success_rate:.1f}%')
|
||||
print(f' Correct Results (5670): {correct_results:.1f}%')
|
||||
|
||||
# Find fastest and slowest
|
||||
fastest = min(successful_results, key=lambda x: x.total_duration)
|
||||
slowest = max(successful_results, key=lambda x: x.total_duration)
|
||||
|
||||
print('\nPerformance Range:')
|
||||
print(f' Fastest: {fastest.total_duration:.3f}s')
|
||||
print(f' Slowest: {slowest.total_duration:.3f}s')
|
||||
|
||||
if fastest.total_duration > 0:
|
||||
speedup = slowest.total_duration / fastest.total_duration
|
||||
print(f' Speed Difference: {speedup:.2f}x')
|
||||
@@ -1,29 +1,10 @@
|
||||
import pytest
|
||||
|
||||
from openhands.core.config import (
|
||||
get_evaluation_parser,
|
||||
get_headless_parser,
|
||||
)
|
||||
from openhands.core.config import get_parser
|
||||
|
||||
|
||||
def test_headless_parser_default_values():
|
||||
parser = get_headless_parser()
|
||||
args = parser.parse_args([])
|
||||
|
||||
assert args.directory is None
|
||||
assert args.task == ''
|
||||
assert args.file is None
|
||||
assert args.agent_cls is None
|
||||
assert args.max_iterations is None
|
||||
assert args.max_budget_per_task is None
|
||||
assert args.llm_config is None
|
||||
assert args.name == ''
|
||||
assert not args.no_auto_continue
|
||||
assert args.selected_repo is None
|
||||
|
||||
|
||||
def test_evaluation_parser_default_values():
|
||||
parser = get_evaluation_parser()
|
||||
def test_parser_default_values():
|
||||
parser = get_parser()
|
||||
args = parser.parse_args([])
|
||||
|
||||
assert args.directory is None
|
||||
@@ -42,8 +23,8 @@ def test_evaluation_parser_default_values():
|
||||
assert args.selected_repo is None
|
||||
|
||||
|
||||
def test_evaluation_parser_custom_values():
|
||||
parser = get_evaluation_parser()
|
||||
def test_parser_custom_values():
|
||||
parser = get_parser()
|
||||
args = parser.parse_args(
|
||||
[
|
||||
'-v',
|
||||
@@ -95,7 +76,7 @@ def test_evaluation_parser_custom_values():
|
||||
|
||||
|
||||
def test_parser_file_overrides_task():
|
||||
parser = get_headless_parser()
|
||||
parser = get_parser()
|
||||
args = parser.parse_args(['-t', 'task from command', '-f', 'task_file.txt'])
|
||||
|
||||
assert args.task == 'task from command'
|
||||
@@ -103,31 +84,31 @@ def test_parser_file_overrides_task():
|
||||
|
||||
|
||||
def test_parser_invalid_max_iterations():
|
||||
parser = get_headless_parser()
|
||||
parser = get_parser()
|
||||
with pytest.raises(SystemExit):
|
||||
parser.parse_args(['-i', 'not_a_number'])
|
||||
|
||||
|
||||
def test_parser_invalid_max_budget():
|
||||
parser = get_headless_parser()
|
||||
parser = get_parser()
|
||||
with pytest.raises(SystemExit):
|
||||
parser.parse_args(['-b', 'not_a_number'])
|
||||
|
||||
|
||||
def test_evaluation_parser_invalid_eval_n_limit():
|
||||
parser = get_evaluation_parser()
|
||||
def test_parser_invalid_eval_n_limit():
|
||||
parser = get_parser()
|
||||
with pytest.raises(SystemExit):
|
||||
parser.parse_args(['--eval-n-limit', 'not_a_number'])
|
||||
|
||||
|
||||
def test_evaluation_parser_invalid_eval_num_workers():
|
||||
parser = get_evaluation_parser()
|
||||
def test_parser_invalid_eval_num_workers():
|
||||
parser = get_parser()
|
||||
with pytest.raises(SystemExit):
|
||||
parser.parse_args(['--eval-num-workers', 'not_a_number'])
|
||||
|
||||
|
||||
def test_headless_parser_help_message(capsys):
|
||||
parser = get_headless_parser()
|
||||
def test_help_message(capsys):
|
||||
parser = get_parser()
|
||||
with pytest.raises(SystemExit):
|
||||
parser.parse_args(['--help'])
|
||||
captured = capsys.readouterr()
|
||||
@@ -145,41 +126,6 @@ def test_headless_parser_help_message(capsys):
|
||||
'-c AGENT_CLS, --agent-cls AGENT_CLS',
|
||||
'-i MAX_ITERATIONS, --max-iterations MAX_ITERATIONS',
|
||||
'-b MAX_BUDGET_PER_TASK, --max-budget-per-task MAX_BUDGET_PER_TASK',
|
||||
'-l LLM_CONFIG, --llm-config LLM_CONFIG',
|
||||
'--agent-config AGENT_CONFIG',
|
||||
'-n NAME, --name NAME',
|
||||
'--config-file CONFIG_FILE',
|
||||
'--no-auto-continue',
|
||||
'--selected-repo SELECTED_REPO',
|
||||
'--log-level LOG_LEVEL',
|
||||
]
|
||||
|
||||
for element in expected_elements:
|
||||
assert element in help_output, f"Expected '{element}' to be in the help message"
|
||||
|
||||
option_count = help_output.count(' -')
|
||||
assert option_count == 15, f'Expected 15 options, found {option_count}'
|
||||
|
||||
|
||||
def test_evaluation_parser_help_message(capsys):
|
||||
parser = get_evaluation_parser()
|
||||
with pytest.raises(SystemExit):
|
||||
parser.parse_args(['--help'])
|
||||
captured = capsys.readouterr()
|
||||
help_output = captured.out
|
||||
print(help_output)
|
||||
expected_elements = [
|
||||
'usage:',
|
||||
'Run OpenHands in evaluation mode',
|
||||
'options:',
|
||||
'-v, --version',
|
||||
'-h, --help',
|
||||
'-d DIRECTORY, --directory DIRECTORY',
|
||||
'-t TASK, --task TASK',
|
||||
'-f FILE, --file FILE',
|
||||
'-c AGENT_CLS, --agent-cls AGENT_CLS',
|
||||
'-i MAX_ITERATIONS, --max-iterations MAX_ITERATIONS',
|
||||
'-b MAX_BUDGET_PER_TASK, --max-budget-per-task MAX_BUDGET_PER_TASK',
|
||||
'--eval-output-dir EVAL_OUTPUT_DIR',
|
||||
'--eval-n-limit EVAL_N_LIMIT',
|
||||
'--eval-num-workers EVAL_NUM_WORKERS',
|
||||
@@ -191,18 +137,20 @@ def test_evaluation_parser_help_message(capsys):
|
||||
'--config-file CONFIG_FILE',
|
||||
'--no-auto-continue',
|
||||
'--selected-repo SELECTED_REPO',
|
||||
'--override-cli-mode OVERRIDE_CLI_MODE',
|
||||
'--log-level LOG_LEVEL',
|
||||
'--conversation CONVERSATION',
|
||||
]
|
||||
|
||||
for element in expected_elements:
|
||||
assert element in help_output, f"Expected '{element}' to be in the help message"
|
||||
|
||||
option_count = help_output.count(' -')
|
||||
assert option_count == 20, f'Expected 20 options, found {option_count}'
|
||||
assert option_count == 22, f'Expected 22 options, found {option_count}'
|
||||
|
||||
|
||||
def test_selected_repo_format():
|
||||
"""Test that the selected-repo argument accepts owner/repo format."""
|
||||
parser = get_headless_parser()
|
||||
parser = get_parser()
|
||||
args = parser.parse_args(['--selected-repo', 'owner/repo'])
|
||||
assert args.selected_repo == 'owner/repo'
|
||||
|
||||
+30
-33
@@ -325,6 +325,7 @@ async def test_run_session_with_initial_action(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.cli.main.parse_arguments')
|
||||
@patch('openhands.cli.main.setup_config_from_args')
|
||||
@patch('openhands.cli.main.FileSettingsStore.get_instance')
|
||||
@patch('openhands.cli.main.check_folder_security_agreement')
|
||||
@@ -344,6 +345,7 @@ async def test_main_without_task(
|
||||
mock_check_security,
|
||||
mock_get_settings_store,
|
||||
mock_setup_config,
|
||||
mock_parse_args,
|
||||
):
|
||||
"""Test main function without a task."""
|
||||
loop = asyncio.get_running_loop()
|
||||
@@ -358,9 +360,7 @@ async def test_main_without_task(
|
||||
mock_args.name = None
|
||||
mock_args.file = None
|
||||
mock_args.conversation = None
|
||||
mock_args.log_level = None
|
||||
mock_args.config_file = 'config.toml'
|
||||
mock_args.override_cli_mode = None
|
||||
mock_parse_args.return_value = mock_args
|
||||
|
||||
# Mock config
|
||||
mock_config = MagicMock()
|
||||
@@ -394,9 +394,10 @@ async def test_main_without_task(
|
||||
mock_run_session.return_value = False
|
||||
|
||||
# Run the function
|
||||
await cli.main_with_loop(loop, mock_args)
|
||||
await cli.main_with_loop(loop)
|
||||
|
||||
# Assertions
|
||||
mock_parse_args.assert_called_once()
|
||||
mock_setup_config.assert_called_once_with(mock_args)
|
||||
mock_get_settings_store.assert_called_once()
|
||||
mock_settings_store.load.assert_called_once()
|
||||
@@ -417,6 +418,7 @@ async def test_main_without_task(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.cli.main.parse_arguments')
|
||||
@patch('openhands.cli.main.setup_config_from_args')
|
||||
@patch('openhands.cli.main.FileSettingsStore.get_instance')
|
||||
@patch('openhands.cli.main.check_folder_security_agreement')
|
||||
@@ -436,6 +438,7 @@ async def test_main_with_task(
|
||||
mock_check_security,
|
||||
mock_get_settings_store,
|
||||
mock_setup_config,
|
||||
mock_parse_args,
|
||||
):
|
||||
"""Test main function with a task."""
|
||||
loop = asyncio.get_running_loop()
|
||||
@@ -448,11 +451,7 @@ async def test_main_with_task(
|
||||
mock_args.agent_cls = 'custom-agent'
|
||||
mock_args.llm_config = 'custom-config'
|
||||
mock_args.file = None
|
||||
mock_args.name = None
|
||||
mock_args.conversation = None
|
||||
mock_args.log_level = None
|
||||
mock_args.config_file = 'config.toml'
|
||||
mock_args.override_cli_mode = None
|
||||
mock_parse_args.return_value = mock_args
|
||||
|
||||
# Mock config
|
||||
mock_config = MagicMock()
|
||||
@@ -487,9 +486,10 @@ async def test_main_with_task(
|
||||
mock_run_session.side_effect = [True, False]
|
||||
|
||||
# Run the function
|
||||
await cli.main_with_loop(loop, mock_args)
|
||||
await cli.main_with_loop(loop)
|
||||
|
||||
# Assertions
|
||||
mock_parse_args.assert_called_once()
|
||||
mock_setup_config.assert_called_once_with(mock_args)
|
||||
mock_get_settings_store.assert_called_once()
|
||||
mock_settings_store.load.assert_called_once()
|
||||
@@ -520,6 +520,7 @@ async def test_main_with_task(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.cli.main.parse_arguments')
|
||||
@patch('openhands.cli.main.setup_config_from_args')
|
||||
@patch('openhands.cli.main.FileSettingsStore.get_instance')
|
||||
@patch('openhands.cli.main.check_folder_security_agreement')
|
||||
@@ -539,6 +540,7 @@ async def test_main_with_session_name_passes_name_to_run_session(
|
||||
mock_check_security,
|
||||
mock_get_settings_store,
|
||||
mock_setup_config,
|
||||
mock_parse_args,
|
||||
):
|
||||
"""Test main function with a session name passes it to run_session."""
|
||||
loop = asyncio.get_running_loop()
|
||||
@@ -554,9 +556,7 @@ async def test_main_with_session_name_passes_name_to_run_session(
|
||||
mock_args.name = test_session_name # Set the session name
|
||||
mock_args.file = None
|
||||
mock_args.conversation = None
|
||||
mock_args.log_level = None
|
||||
mock_args.config_file = 'config.toml'
|
||||
mock_args.override_cli_mode = None
|
||||
mock_parse_args.return_value = mock_args
|
||||
|
||||
# Mock config
|
||||
mock_config = MagicMock()
|
||||
@@ -590,9 +590,10 @@ async def test_main_with_session_name_passes_name_to_run_session(
|
||||
mock_run_session.return_value = False
|
||||
|
||||
# Run the function
|
||||
await cli.main_with_loop(loop, mock_args)
|
||||
await cli.main_with_loop(loop)
|
||||
|
||||
# Assertions
|
||||
mock_parse_args.assert_called_once()
|
||||
mock_setup_config.assert_called_once_with(mock_args)
|
||||
mock_get_settings_store.assert_called_once()
|
||||
mock_settings_store.load.assert_called_once()
|
||||
@@ -712,6 +713,7 @@ async def test_run_session_with_name_attempts_state_restore(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.cli.main.parse_arguments')
|
||||
@patch('openhands.cli.main.setup_config_from_args')
|
||||
@patch('openhands.cli.main.FileSettingsStore.get_instance')
|
||||
@patch('openhands.cli.main.check_folder_security_agreement')
|
||||
@@ -731,6 +733,7 @@ async def test_main_security_check_fails(
|
||||
mock_check_security,
|
||||
mock_get_settings_store,
|
||||
mock_setup_config,
|
||||
mock_parse_args,
|
||||
):
|
||||
"""Test main function when security check fails."""
|
||||
loop = asyncio.get_running_loop()
|
||||
@@ -740,14 +743,7 @@ async def test_main_security_check_fails(
|
||||
|
||||
# Mock arguments
|
||||
mock_args = MagicMock()
|
||||
mock_args.agent_cls = None
|
||||
mock_args.llm_config = None
|
||||
mock_args.name = None
|
||||
mock_args.file = None
|
||||
mock_args.conversation = None
|
||||
mock_args.log_level = None
|
||||
mock_args.config_file = 'config.toml'
|
||||
mock_args.override_cli_mode = None
|
||||
mock_parse_args.return_value = mock_args
|
||||
|
||||
# Mock config
|
||||
mock_config = MagicMock()
|
||||
@@ -769,9 +765,10 @@ async def test_main_security_check_fails(
|
||||
mock_check_security.return_value = False
|
||||
|
||||
# Run the function
|
||||
await cli.main_with_loop(loop, mock_args)
|
||||
await cli.main_with_loop(loop)
|
||||
|
||||
# Assertions
|
||||
mock_parse_args.assert_called_once()
|
||||
mock_setup_config.assert_called_once_with(mock_args)
|
||||
mock_get_settings_store.assert_called_once()
|
||||
mock_settings_store.load.assert_called_once()
|
||||
@@ -782,6 +779,7 @@ async def test_main_security_check_fails(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.cli.main.parse_arguments')
|
||||
@patch('openhands.cli.main.setup_config_from_args')
|
||||
@patch('openhands.cli.main.FileSettingsStore.get_instance')
|
||||
@patch('openhands.cli.main.check_folder_security_agreement')
|
||||
@@ -801,6 +799,7 @@ async def test_config_loading_order(
|
||||
mock_check_security,
|
||||
mock_get_settings_store,
|
||||
mock_setup_config,
|
||||
mock_parse_args,
|
||||
):
|
||||
"""Test the order of configuration loading in the main function.
|
||||
|
||||
@@ -821,10 +820,7 @@ async def test_config_loading_order(
|
||||
# Add a file property to avoid file I/O errors
|
||||
mock_args.file = None
|
||||
mock_args.log_level = 'INFO'
|
||||
mock_args.name = None
|
||||
mock_args.conversation = None
|
||||
mock_args.config_file = 'config.toml'
|
||||
mock_args.override_cli_mode = None
|
||||
mock_parse_args.return_value = mock_args
|
||||
|
||||
# Mock read_task to return a dummy task
|
||||
mock_read_task.return_value = 'Test task'
|
||||
@@ -867,9 +863,10 @@ async def test_config_loading_order(
|
||||
mock_run_session.return_value = False # No new session requested
|
||||
|
||||
# Run the function
|
||||
await cli.main_with_loop(loop, mock_args)
|
||||
await cli.main_with_loop(loop)
|
||||
|
||||
# Assertions for argument parsing and config setup
|
||||
mock_parse_args.assert_called_once()
|
||||
mock_setup_config.assert_called_once_with(mock_args)
|
||||
mock_get_settings_store.assert_called_once()
|
||||
mock_settings_store.load.assert_called_once()
|
||||
@@ -899,6 +896,7 @@ async def test_config_loading_order(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.cli.main.parse_arguments')
|
||||
@patch('openhands.cli.main.setup_config_from_args')
|
||||
@patch('openhands.cli.main.FileSettingsStore.get_instance')
|
||||
@patch('openhands.cli.main.check_folder_security_agreement')
|
||||
@@ -920,6 +918,7 @@ async def test_main_with_file_option(
|
||||
mock_check_security,
|
||||
mock_get_settings_store,
|
||||
mock_setup_config,
|
||||
mock_parse_args,
|
||||
):
|
||||
"""Test main function with a file option."""
|
||||
loop = asyncio.get_running_loop()
|
||||
@@ -934,10 +933,7 @@ async def test_main_with_file_option(
|
||||
mock_args.name = None
|
||||
mock_args.file = '/path/to/test/file.txt'
|
||||
mock_args.task = None
|
||||
mock_args.conversation = None
|
||||
mock_args.log_level = None
|
||||
mock_args.config_file = 'config.toml'
|
||||
mock_args.override_cli_mode = None
|
||||
mock_parse_args.return_value = mock_args
|
||||
|
||||
# Mock config
|
||||
mock_config = MagicMock()
|
||||
@@ -973,9 +969,10 @@ async def test_main_with_file_option(
|
||||
mock_run_session.return_value = False
|
||||
|
||||
# Run the function
|
||||
await cli.main_with_loop(loop, mock_args)
|
||||
await cli.main_with_loop(loop)
|
||||
|
||||
# Assertions
|
||||
mock_parse_args.assert_called_once()
|
||||
mock_setup_config.assert_called_once_with(mock_args)
|
||||
mock_get_settings_store.assert_called_once()
|
||||
mock_settings_store.load.assert_called_once()
|
||||
|
||||
@@ -13,7 +13,6 @@ from openhands.integrations.service_types import (
|
||||
Repository,
|
||||
)
|
||||
from openhands.microagent.types import MicroagentContentResponse
|
||||
from openhands.server.dependencies import check_session_api_key
|
||||
from openhands.server.routes.git import app as git_app
|
||||
from openhands.server.user_auth import (
|
||||
get_access_token,
|
||||
@@ -50,15 +49,10 @@ def test_client():
|
||||
def mock_get_user_id():
|
||||
return 'test_user'
|
||||
|
||||
def mock_check_session_api_key():
|
||||
# Mock session API key check to always pass for tests
|
||||
return None
|
||||
|
||||
# Override the dependencies in the app
|
||||
app.dependency_overrides[get_provider_tokens] = mock_get_provider_tokens
|
||||
app.dependency_overrides[get_access_token] = mock_get_access_token
|
||||
app.dependency_overrides[get_user_id] = mock_get_user_id
|
||||
app.dependency_overrides[check_session_api_key] = mock_check_session_api_key
|
||||
|
||||
yield TestClient(app)
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import litellm
|
||||
import pytest
|
||||
|
||||
# Set a dummy API key to avoid authentication errors
|
||||
os.environ['GEMINI_API_KEY'] = 'dummy_key'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_thinking_parameter_is_not_sent_to_gemini():
|
||||
"""
|
||||
Tests that the 'thinking' parameter is NOT included in the final
|
||||
request sent to Gemini, as it should be handled before the API call.
|
||||
This test patches the final HTTP call to inspect the payload.
|
||||
"""
|
||||
# The path to the method that sends the final request in litellm
|
||||
patch_target = 'litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post'
|
||||
|
||||
with patch(patch_target, new_callable=AsyncMock) as mock_post:
|
||||
# Configure the mock to return a future-like object with a dummy response
|
||||
# This simulates a successful API call
|
||||
mock_post.return_value.status_code = 200
|
||||
mock_post.return_value.json.return_value = {
|
||||
'choices': [
|
||||
{
|
||||
'message': {
|
||||
'role': 'assistant',
|
||||
'content': 'This is a mock response.',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# Simulate the call as OpenHands would, including the 'thinking' parameter
|
||||
# We need to drop params, since litellm 1.18.0+ validates gemini params
|
||||
original_drop_params = litellm.drop_params
|
||||
litellm.drop_params = True
|
||||
try:
|
||||
await litellm.acompletion(
|
||||
model='gemini/gemini-pro',
|
||||
messages=[{'role': 'user', 'content': 'Test prompt'}],
|
||||
thinking={'budget_tokens': 500},
|
||||
)
|
||||
except Exception as e:
|
||||
# We don't want the test to fail if litellm throws an exception
|
||||
# after our patch, as we are only interested in the call arguments.
|
||||
print(f'litellm.acompletion call resulted in an exception (ignored): {e}')
|
||||
finally:
|
||||
litellm.drop_params = original_drop_params
|
||||
|
||||
# Assert that the post method was called at least once
|
||||
mock_post.assert_called()
|
||||
|
||||
# Get the arguments of the last call to the mock
|
||||
args, kwargs = mock_post.call_args
|
||||
|
||||
# Extract the JSON payload from the keyword arguments
|
||||
final_json_payload = kwargs.get('json', {})
|
||||
|
||||
# The core of the test: assert that 'thinking' is not in the payload
|
||||
assert 'thinking' not in final_json_payload, (
|
||||
f"'thinking' parameter was found in the final request payload: {final_json_payload}"
|
||||
)
|
||||
|
||||
# Optional: Save the captured payload for inspection
|
||||
with open('litellm_final_request.json', 'w') as f:
|
||||
json.dump(final_json_payload, f, indent=2)
|
||||
@@ -0,0 +1,272 @@
|
||||
"""Tests for Gemini thinking patch functionality in LLM class."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from openhands.core.config import LLMConfig
|
||||
from openhands.llm.llm import LLM
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_logger(monkeypatch):
|
||||
"""Suppress logging during tests."""
|
||||
mock_logger = MagicMock()
|
||||
monkeypatch.setattr('openhands.llm.debug_mixin.llm_prompt_logger', mock_logger)
|
||||
monkeypatch.setattr('openhands.llm.debug_mixin.llm_response_logger', mock_logger)
|
||||
monkeypatch.setattr('openhands.llm.llm.logger', mock_logger)
|
||||
return mock_logger
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gemini_config():
|
||||
"""LLM config for Gemini 2.5 Pro model."""
|
||||
return LLMConfig(
|
||||
model='gemini-2.5-pro',
|
||||
api_key='test_key',
|
||||
num_retries=1,
|
||||
retry_min_wait=1,
|
||||
retry_max_wait=2,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gpt_config():
|
||||
"""LLM config for GPT-4 model."""
|
||||
return LLMConfig(
|
||||
model='gpt-4',
|
||||
api_key='test_key',
|
||||
num_retries=1,
|
||||
retry_min_wait=1,
|
||||
retry_max_wait=2,
|
||||
)
|
||||
|
||||
|
||||
class TestGeminiThinkingPatch:
|
||||
"""Test suite for Gemini thinking patch functionality."""
|
||||
|
||||
def test_should_apply_gemini_thinking_patch_for_gemini_models(self, gemini_config):
|
||||
"""Test that Gemini models are correctly identified for patching."""
|
||||
llm = LLM(gemini_config)
|
||||
assert llm._should_apply_gemini_thinking_patch() is True
|
||||
|
||||
def test_should_not_apply_gemini_thinking_patch_for_non_gemini_models(
|
||||
self, gpt_config
|
||||
):
|
||||
"""Test that non-Gemini models are not identified for patching."""
|
||||
llm = LLM(gpt_config)
|
||||
assert llm._should_apply_gemini_thinking_patch() is False
|
||||
|
||||
def test_should_apply_gemini_thinking_patch_case_insensitive(self):
|
||||
"""Test that patch detection is case insensitive."""
|
||||
config = LLMConfig(model='GEMINI-2.5-PRO', api_key='test_key')
|
||||
llm = LLM(config)
|
||||
assert llm._should_apply_gemini_thinking_patch() is True
|
||||
|
||||
def test_gemini_thinking_patch_context_manager_creation(self, gemini_config):
|
||||
"""Test that context manager can be created successfully."""
|
||||
llm = LLM(gemini_config)
|
||||
context_manager = llm._gemini_thinking_patch_context()
|
||||
assert context_manager is not None
|
||||
|
||||
def test_gemini_thinking_patch_context_manager_no_patch_for_non_gemini(
|
||||
self, gpt_config
|
||||
):
|
||||
"""Test that context manager works correctly for non-Gemini models."""
|
||||
llm = LLM(gpt_config)
|
||||
|
||||
# Should not raise any exceptions and should work as a no-op
|
||||
with llm._gemini_thinking_patch_context():
|
||||
pass
|
||||
|
||||
@patch('litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini')
|
||||
def test_gemini_thinking_patch_function_patching_and_restoration(
|
||||
self, mock_gemini_module, gemini_config
|
||||
):
|
||||
"""Test that functions are properly patched and restored."""
|
||||
# Setup mock module
|
||||
original_sync_func = MagicMock()
|
||||
original_async_func = MagicMock()
|
||||
original_sync_func.__name__ = 'sync_transform_request_body'
|
||||
original_async_func.__name__ = 'async_transform_request_body'
|
||||
|
||||
mock_gemini_module.sync_transform_request_body = original_sync_func
|
||||
mock_gemini_module.async_transform_request_body = original_async_func
|
||||
|
||||
llm = LLM(gemini_config)
|
||||
|
||||
# Test that functions are patched inside context
|
||||
with llm._gemini_thinking_patch_context():
|
||||
# Functions should be different (patched)
|
||||
assert mock_gemini_module.sync_transform_request_body != original_sync_func
|
||||
assert (
|
||||
mock_gemini_module.async_transform_request_body != original_async_func
|
||||
)
|
||||
|
||||
# Functions should be restored after context
|
||||
assert mock_gemini_module.sync_transform_request_body == original_sync_func
|
||||
assert mock_gemini_module.async_transform_request_body == original_async_func
|
||||
|
||||
@patch('litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini')
|
||||
def test_gemini_thinking_patch_adds_thinking_config(
|
||||
self, mock_gemini_module, gemini_config
|
||||
):
|
||||
"""Test that the patch correctly adds thinkingConfig to optional_params."""
|
||||
# Setup mock module
|
||||
original_sync_func = MagicMock()
|
||||
original_sync_func.__name__ = 'sync_transform_request_body'
|
||||
mock_gemini_module.sync_transform_request_body = original_sync_func
|
||||
|
||||
llm = LLM(gemini_config)
|
||||
|
||||
with llm._gemini_thinking_patch_context():
|
||||
# Get the patched function
|
||||
patched_func = mock_gemini_module.sync_transform_request_body
|
||||
|
||||
# Call the patched function with optional_params
|
||||
test_kwargs = {'optional_params': {'temperature': 0.5}}
|
||||
patched_func('test_arg', **test_kwargs)
|
||||
|
||||
# Verify thinkingConfig was added
|
||||
expected_thinking_config = {'includeThoughts': True}
|
||||
assert (
|
||||
test_kwargs['optional_params']['thinkingConfig']
|
||||
== expected_thinking_config
|
||||
)
|
||||
|
||||
# Verify original function was called
|
||||
original_sync_func.assert_called_once_with('test_arg', **test_kwargs)
|
||||
|
||||
@patch('litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini')
|
||||
def test_gemini_thinking_patch_handles_missing_optional_params(
|
||||
self, mock_gemini_module, gemini_config
|
||||
):
|
||||
"""Test that the patch handles cases where optional_params is missing."""
|
||||
# Setup mock module
|
||||
original_sync_func = MagicMock()
|
||||
original_sync_func.__name__ = 'sync_transform_request_body'
|
||||
mock_gemini_module.sync_transform_request_body = original_sync_func
|
||||
|
||||
llm = LLM(gemini_config)
|
||||
|
||||
with llm._gemini_thinking_patch_context():
|
||||
# Get the patched function
|
||||
patched_func = mock_gemini_module.sync_transform_request_body
|
||||
|
||||
# Call the patched function without optional_params
|
||||
test_kwargs = {}
|
||||
patched_func('test_arg', **test_kwargs)
|
||||
|
||||
# Should not raise an error and should call original function
|
||||
original_sync_func.assert_called_once_with('test_arg', **test_kwargs)
|
||||
|
||||
def test_gemini_thinking_patch_handles_import_error(self, gemini_config):
|
||||
"""Test that import errors are handled gracefully."""
|
||||
llm = LLM(gemini_config)
|
||||
|
||||
# Should not raise an exception even if modules are missing
|
||||
with llm._gemini_thinking_patch_context():
|
||||
pass
|
||||
|
||||
def test_gemini_thinking_patch_handles_general_exception(self, gemini_config):
|
||||
"""Test that general exceptions during patching are handled gracefully."""
|
||||
llm = LLM(gemini_config)
|
||||
|
||||
# Should not raise an exception
|
||||
with llm._gemini_thinking_patch_context():
|
||||
pass
|
||||
|
||||
@patch('litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini')
|
||||
def test_gemini_thinking_patch_restoration_on_exception(
|
||||
self, mock_gemini_module, gemini_config
|
||||
):
|
||||
"""Test that functions are restored even if an exception occurs inside the context."""
|
||||
# Setup mock module
|
||||
original_sync_func = MagicMock()
|
||||
original_sync_func.__name__ = 'sync_transform_request_body'
|
||||
mock_gemini_module.sync_transform_request_body = original_sync_func
|
||||
|
||||
llm = LLM(gemini_config)
|
||||
|
||||
# Test that functions are restored even when exception occurs
|
||||
try:
|
||||
with llm._gemini_thinking_patch_context():
|
||||
# Functions should be patched
|
||||
assert (
|
||||
mock_gemini_module.sync_transform_request_body != original_sync_func
|
||||
)
|
||||
# Raise an exception
|
||||
raise ValueError('Test exception')
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Functions should still be restored after exception
|
||||
assert mock_gemini_module.sync_transform_request_body == original_sync_func
|
||||
|
||||
def test_gemini_thinking_patch_multiple_models_isolation(
|
||||
self, gemini_config, gpt_config
|
||||
):
|
||||
"""Test that patches are isolated between different model instances."""
|
||||
gemini_llm = LLM(gemini_config)
|
||||
gpt_llm = LLM(gpt_config)
|
||||
|
||||
# Gemini should have patch capability
|
||||
assert gemini_llm._should_apply_gemini_thinking_patch() is True
|
||||
|
||||
# GPT should not have patch capability
|
||||
assert gpt_llm._should_apply_gemini_thinking_patch() is False
|
||||
|
||||
# Both should be able to create context managers without interference
|
||||
with gemini_llm._gemini_thinking_patch_context():
|
||||
with gpt_llm._gemini_thinking_patch_context():
|
||||
pass
|
||||
|
||||
@patch('litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini')
|
||||
def test_gemini_thinking_patch_async_function_handling(
|
||||
self, mock_gemini_module, gemini_config
|
||||
):
|
||||
"""Test that async functions are properly handled when available."""
|
||||
# Setup mock module with both sync and async functions
|
||||
original_sync_func = MagicMock()
|
||||
original_async_func = MagicMock()
|
||||
original_sync_func.__name__ = 'sync_transform_request_body'
|
||||
original_async_func.__name__ = 'async_transform_request_body'
|
||||
|
||||
mock_gemini_module.sync_transform_request_body = original_sync_func
|
||||
mock_gemini_module.async_transform_request_body = original_async_func
|
||||
|
||||
llm = LLM(gemini_config)
|
||||
|
||||
with llm._gemini_thinking_patch_context():
|
||||
# Both functions should be patched
|
||||
assert mock_gemini_module.sync_transform_request_body != original_sync_func
|
||||
assert (
|
||||
mock_gemini_module.async_transform_request_body != original_async_func
|
||||
)
|
||||
|
||||
# Both functions should be restored
|
||||
assert mock_gemini_module.sync_transform_request_body == original_sync_func
|
||||
assert mock_gemini_module.async_transform_request_body == original_async_func
|
||||
|
||||
@patch('litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini')
|
||||
def test_gemini_thinking_patch_no_async_function(
|
||||
self, mock_gemini_module, gemini_config
|
||||
):
|
||||
"""Test that patch works correctly when async function is not available."""
|
||||
# Setup mock module with only sync function
|
||||
original_sync_func = MagicMock()
|
||||
original_sync_func.__name__ = 'sync_transform_request_body'
|
||||
|
||||
mock_gemini_module.sync_transform_request_body = original_sync_func
|
||||
# Simulate missing async function
|
||||
del mock_gemini_module.async_transform_request_body
|
||||
|
||||
llm = LLM(gemini_config)
|
||||
|
||||
# Should not raise an exception
|
||||
with llm._gemini_thinking_patch_context():
|
||||
# Sync function should be patched
|
||||
assert mock_gemini_module.sync_transform_request_body != original_sync_func
|
||||
|
||||
# Sync function should be restored
|
||||
assert mock_gemini_module.sync_transform_request_body == original_sync_func
|
||||
@@ -46,32 +46,24 @@ def test_localhost_cors_middleware_init_without_env_var():
|
||||
|
||||
|
||||
def test_localhost_cors_middleware_is_allowed_origin_localhost(app):
|
||||
"""Test that localhost origins are allowed regardless of port when no specific origins are configured."""
|
||||
# Test without setting PERMITTED_CORS_ORIGINS to trigger localhost behavior
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
app.add_middleware(LocalhostCORSMiddleware)
|
||||
client = TestClient(app)
|
||||
"""Test that localhost origins are allowed regardless of port."""
|
||||
app.add_middleware(LocalhostCORSMiddleware)
|
||||
client = TestClient(app)
|
||||
|
||||
# Test with localhost
|
||||
response = client.get('/test', headers={'Origin': 'http://localhost:8000'})
|
||||
assert response.status_code == 200
|
||||
assert (
|
||||
response.headers['access-control-allow-origin'] == 'http://localhost:8000'
|
||||
)
|
||||
# Test with localhost
|
||||
response = client.get('/test', headers={'Origin': 'http://localhost:8000'})
|
||||
assert response.status_code == 200
|
||||
assert response.headers['access-control-allow-origin'] == 'http://localhost:8000'
|
||||
|
||||
# Test with different port
|
||||
response = client.get('/test', headers={'Origin': 'http://localhost:3000'})
|
||||
assert response.status_code == 200
|
||||
assert (
|
||||
response.headers['access-control-allow-origin'] == 'http://localhost:3000'
|
||||
)
|
||||
# Test with different port
|
||||
response = client.get('/test', headers={'Origin': 'http://localhost:3000'})
|
||||
assert response.status_code == 200
|
||||
assert response.headers['access-control-allow-origin'] == 'http://localhost:3000'
|
||||
|
||||
# Test with 127.0.0.1
|
||||
response = client.get('/test', headers={'Origin': 'http://127.0.0.1:8000'})
|
||||
assert response.status_code == 200
|
||||
assert (
|
||||
response.headers['access-control-allow-origin'] == 'http://127.0.0.1:8000'
|
||||
)
|
||||
# Test with 127.0.0.1
|
||||
response = client.get('/test', headers={'Origin': 'http://127.0.0.1:8000'})
|
||||
assert response.status_code == 200
|
||||
assert response.headers['access-control-allow-origin'] == 'http://127.0.0.1:8000'
|
||||
|
||||
|
||||
def test_localhost_cors_middleware_is_allowed_origin_non_localhost(app):
|
||||
@@ -95,15 +87,14 @@ def test_localhost_cors_middleware_is_allowed_origin_non_localhost(app):
|
||||
|
||||
def test_localhost_cors_middleware_missing_origin(app):
|
||||
"""Test behavior when Origin header is missing."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
app.add_middleware(LocalhostCORSMiddleware)
|
||||
client = TestClient(app)
|
||||
app.add_middleware(LocalhostCORSMiddleware)
|
||||
client = TestClient(app)
|
||||
|
||||
# Test without Origin header
|
||||
response = client.get('/test')
|
||||
assert response.status_code == 200
|
||||
# There should be no access-control-allow-origin header
|
||||
assert 'access-control-allow-origin' not in response.headers
|
||||
# Test without Origin header
|
||||
response = client.get('/test')
|
||||
assert response.status_code == 200
|
||||
# There should be no access-control-allow-origin header
|
||||
assert 'access-control-allow-origin' not in response.headers
|
||||
|
||||
|
||||
def test_localhost_cors_middleware_inheritance():
|
||||
|
||||
Reference in New Issue
Block a user