mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
29 Commits
fix/git-di
...
feature/cl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bbffa1769 | ||
|
|
54e2f2aba8 | ||
|
|
953f99a147 | ||
|
|
1d78513407 | ||
|
|
d51c6bb992 | ||
|
|
1cd8eada2b | ||
|
|
bcb1180e47 | ||
|
|
3aa8531826 | ||
|
|
44c4e0e5fd | ||
|
|
a9982f96c6 | ||
|
|
7112b4e329 | ||
|
|
c2d1d15a8f | ||
|
|
d2bb882c96 | ||
|
|
e995882194 | ||
|
|
ef1441bbe5 | ||
|
|
27512ee72c | ||
|
|
8a50164c45 | ||
|
|
1c54f333c5 | ||
|
|
e6ddf09897 | ||
|
|
d9f311a398 | ||
|
|
f3d74ab807 | ||
|
|
6dbbf76231 | ||
|
|
1231b78aea | ||
|
|
9003f40096 | ||
|
|
f70f649745 | ||
|
|
7939bd694b | ||
|
|
916bb85244 | ||
|
|
4ef1dde5f6 | ||
|
|
cf982e0134 |
58
.github/workflows/cli-build-test.yml
vendored
Normal file
58
.github/workflows/cli-build-test.yml
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
# Workflow that builds and tests the CLI binary executable
|
||||
name: CLI - Build and Test Binary
|
||||
|
||||
# Run on pushes to main branch and all pull requests, but only when CLI files change
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "openhands-cli/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- "openhands-cli/**"
|
||||
|
||||
# Cancel previous runs if a new commit is pushed
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-test-binary:
|
||||
name: Build and test binary executable
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.12
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v3
|
||||
with:
|
||||
version: "latest"
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: openhands-cli
|
||||
run: |
|
||||
uv sync
|
||||
|
||||
- name: Build binary executable
|
||||
working-directory: openhands-cli
|
||||
run: |
|
||||
./build.sh --install-pyinstaller | tee output.log
|
||||
echo "Full output:"
|
||||
cat output.log
|
||||
|
||||
if grep -q "❌" output.log; then
|
||||
echo "❌ Found failure marker in output"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Build & test finished without ❌ markers"
|
||||
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
||||
npm run make-i18n && tsc
|
||||
npm run check-translation-completeness
|
||||
|
||||
# Run lint on the python code
|
||||
# Run lint on the python code (excluding CLI and enterprise)
|
||||
lint-python:
|
||||
name: Lint python
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -31,7 +31,8 @@ requirements.txt
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
# Note: openhands-cli.spec is intentionally tracked for CLI builds
|
||||
# *.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
|
||||
251
docs/usage/environment-variables.mdx
Normal file
251
docs/usage/environment-variables.mdx
Normal file
@@ -0,0 +1,251 @@
|
||||
---
|
||||
title: Environment Variables Reference
|
||||
description: Complete reference of all environment variables supported by OpenHands
|
||||
---
|
||||
|
||||
This page provides a reference of environment variables that can be used to configure OpenHands. Environment variables provide an alternative to TOML configuration files and are particularly useful for containerized deployments, CI/CD pipelines, and cloud environments.
|
||||
|
||||
## Environment Variable Naming Convention
|
||||
|
||||
OpenHands follows a consistent naming pattern for environment variables:
|
||||
|
||||
- **Core settings**: Direct uppercase mapping (e.g., `debug` → `DEBUG`)
|
||||
- **LLM settings**: Prefixed with `LLM_` (e.g., `model` → `LLM_MODEL`)
|
||||
- **Agent settings**: Prefixed with `AGENT_` (e.g., `enable_browsing` → `AGENT_ENABLE_BROWSING`)
|
||||
- **Sandbox settings**: Prefixed with `SANDBOX_` (e.g., `timeout` → `SANDBOX_TIMEOUT`)
|
||||
- **Security settings**: Prefixed with `SECURITY_` (e.g., `confirmation_mode` → `SECURITY_CONFIRMATION_MODE`)
|
||||
|
||||
## Core Configuration Variables
|
||||
|
||||
These variables correspond to the `[core]` section in `config.toml`:
|
||||
|
||||
| Environment Variable | Type | Default | Description |
|
||||
|---------------------|------|---------|-------------|
|
||||
| `DEBUG` | boolean | `false` | Enable debug logging throughout the application |
|
||||
| `DISABLE_COLOR` | boolean | `false` | Disable colored output in terminal |
|
||||
| `CACHE_DIR` | string | `"/tmp/cache"` | Directory path for caching |
|
||||
| `SAVE_TRAJECTORY_PATH` | string | `"./trajectories"` | Path to store conversation trajectories |
|
||||
| `REPLAY_TRAJECTORY_PATH` | string | `""` | Path to load and replay a trajectory file |
|
||||
| `FILE_STORE_PATH` | string | `"/tmp/file_store"` | File store directory path |
|
||||
| `FILE_STORE` | string | `"memory"` | File store type (`memory`, `local`, etc.) |
|
||||
| `FILE_UPLOADS_MAX_FILE_SIZE_MB` | integer | `0` | Maximum file upload size in MB (0 = no limit) |
|
||||
| `FILE_UPLOADS_RESTRICT_FILE_TYPES` | boolean | `false` | Whether to restrict file upload types |
|
||||
| `FILE_UPLOADS_ALLOWED_EXTENSIONS` | list | `[".*"]` | List of allowed file extensions for uploads |
|
||||
| `MAX_BUDGET_PER_TASK` | float | `0.0` | Maximum budget per task (0.0 = no limit) |
|
||||
| `MAX_ITERATIONS` | integer | `100` | Maximum number of iterations per task |
|
||||
| `RUNTIME` | string | `"docker"` | Runtime environment (`docker`, `local`, `cli`, etc.) |
|
||||
| `DEFAULT_AGENT` | string | `"CodeActAgent"` | Default agent class to use |
|
||||
| `JWT_SECRET` | string | auto-generated | JWT secret for authentication |
|
||||
| `RUN_AS_OPENHANDS` | boolean | `true` | Whether to run as the openhands user |
|
||||
| `VOLUMES` | string | `""` | Volume mounts in format `host:container[:mode]` |
|
||||
|
||||
## LLM Configuration Variables
|
||||
|
||||
These variables correspond to the `[llm]` section in `config.toml`:
|
||||
|
||||
| Environment Variable | Type | Default | Description |
|
||||
|---------------------|------|---------|-------------|
|
||||
| `LLM_MODEL` | string | `"claude-3-5-sonnet-20241022"` | LLM model to use |
|
||||
| `LLM_API_KEY` | string | `""` | API key for the LLM provider |
|
||||
| `LLM_BASE_URL` | string | `""` | Custom API base URL |
|
||||
| `LLM_API_VERSION` | string | `""` | API version to use |
|
||||
| `LLM_TEMPERATURE` | float | `0.0` | Sampling temperature |
|
||||
| `LLM_TOP_P` | float | `1.0` | Top-p sampling parameter |
|
||||
| `LLM_MAX_INPUT_TOKENS` | integer | `0` | Maximum input tokens (0 = no limit) |
|
||||
| `LLM_MAX_OUTPUT_TOKENS` | integer | `0` | Maximum output tokens (0 = no limit) |
|
||||
| `LLM_MAX_MESSAGE_CHARS` | integer | `30000` | Maximum characters that will be sent to the model in observation content |
|
||||
| `LLM_TIMEOUT` | integer | `0` | API timeout in seconds (0 = no timeout) |
|
||||
| `LLM_NUM_RETRIES` | integer | `8` | Number of retry attempts |
|
||||
| `LLM_RETRY_MIN_WAIT` | integer | `15` | Minimum wait time between retries (seconds) |
|
||||
| `LLM_RETRY_MAX_WAIT` | integer | `120` | Maximum wait time between retries (seconds) |
|
||||
| `LLM_RETRY_MULTIPLIER` | float | `2.0` | Exponential backoff multiplier |
|
||||
| `LLM_DROP_PARAMS` | boolean | `false` | Drop unsupported parameters without error |
|
||||
| `LLM_CACHING_PROMPT` | boolean | `true` | Enable prompt caching if supported |
|
||||
| `LLM_DISABLE_VISION` | boolean | `false` | Disable vision capabilities for cost reduction |
|
||||
| `LLM_CUSTOM_LLM_PROVIDER` | string | `""` | Custom LLM provider name |
|
||||
| `LLM_OLLAMA_BASE_URL` | string | `""` | Base URL for Ollama API |
|
||||
| `LLM_INPUT_COST_PER_TOKEN` | float | `0.0` | Cost per input token |
|
||||
| `LLM_OUTPUT_COST_PER_TOKEN` | float | `0.0` | Cost per output token |
|
||||
| `LLM_REASONING_EFFORT` | string | `""` | Reasoning effort for o-series models (`low`, `medium`, `high`) |
|
||||
|
||||
### AWS Configuration
|
||||
| Environment Variable | Type | Default | Description |
|
||||
|---------------------|------|---------|-------------|
|
||||
| `LLM_AWS_ACCESS_KEY_ID` | string | `""` | AWS access key ID |
|
||||
| `LLM_AWS_SECRET_ACCESS_KEY` | string | `""` | AWS secret access key |
|
||||
| `LLM_AWS_REGION_NAME` | string | `""` | AWS region name |
|
||||
|
||||
## Agent Configuration Variables
|
||||
|
||||
These variables correspond to the `[agent]` section in `config.toml`:
|
||||
|
||||
| Environment Variable | Type | Default | Description |
|
||||
|---------------------|------|---------|-------------|
|
||||
| `AGENT_LLM_CONFIG` | string | `""` | Name of LLM config group to use |
|
||||
| `AGENT_FUNCTION_CALLING` | boolean | `true` | Enable function calling |
|
||||
| `AGENT_ENABLE_BROWSING` | boolean | `false` | Enable browsing delegate |
|
||||
| `AGENT_ENABLE_LLM_EDITOR` | boolean | `false` | Enable LLM-based editor |
|
||||
| `AGENT_ENABLE_JUPYTER` | boolean | `false` | Enable Jupyter integration |
|
||||
| `AGENT_ENABLE_HISTORY_TRUNCATION` | boolean | `true` | Enable history truncation |
|
||||
| `AGENT_ENABLE_PROMPT_EXTENSIONS` | boolean | `true` | Enable microagents (prompt extensions) |
|
||||
| `AGENT_DISABLED_MICROAGENTS` | list | `[]` | List of microagents to disable |
|
||||
|
||||
## Sandbox Configuration Variables
|
||||
|
||||
These variables correspond to the `[sandbox]` section in `config.toml`:
|
||||
|
||||
| Environment Variable | Type | Default | Description |
|
||||
|---------------------|------|---------|-------------|
|
||||
| `SANDBOX_TIMEOUT` | integer | `120` | Sandbox timeout in seconds |
|
||||
| `SANDBOX_USER_ID` | integer | `1000` | User ID for sandbox processes |
|
||||
| `SANDBOX_BASE_CONTAINER_IMAGE` | string | `"nikolaik/python-nodejs:python3.12-nodejs22"` | Base container image |
|
||||
| `SANDBOX_USE_HOST_NETWORK` | boolean | `false` | Use host networking |
|
||||
| `SANDBOX_RUNTIME_BINDING_ADDRESS` | string | `"0.0.0.0"` | Runtime binding address |
|
||||
| `SANDBOX_ENABLE_AUTO_LINT` | boolean | `false` | Enable automatic linting |
|
||||
| `SANDBOX_INITIALIZE_PLUGINS` | boolean | `true` | Initialize sandbox plugins |
|
||||
| `SANDBOX_RUNTIME_EXTRA_DEPS` | string | `""` | Extra dependencies to install |
|
||||
| `SANDBOX_RUNTIME_STARTUP_ENV_VARS` | dict | `{}` | Environment variables for runtime |
|
||||
| `SANDBOX_BROWSERGYM_EVAL_ENV` | string | `""` | BrowserGym evaluation environment |
|
||||
| `SANDBOX_VOLUMES` | string | `""` | Volume mounts (replaces deprecated workspace settings) |
|
||||
| `SANDBOX_RUNTIME_CONTAINER_IMAGE` | string | `""` | Pre-built runtime container image |
|
||||
| `SANDBOX_KEEP_RUNTIME_ALIVE` | boolean | `false` | Keep runtime alive after session ends |
|
||||
| `SANDBOX_PAUSE_CLOSED_RUNTIMES` | boolean | `false` | Pause instead of stopping closed runtimes |
|
||||
| `SANDBOX_CLOSE_DELAY` | integer | `300` | Delay before closing idle runtimes (seconds) |
|
||||
| `SANDBOX_RM_ALL_CONTAINERS` | boolean | `false` | Remove all containers when stopping |
|
||||
| `SANDBOX_ENABLE_GPU` | boolean | `false` | Enable GPU support |
|
||||
| `SANDBOX_CUDA_VISIBLE_DEVICES` | string | `""` | Specify GPU devices by ID |
|
||||
| `SANDBOX_VSCODE_PORT` | integer | auto | Specific port for VSCode server |
|
||||
|
||||
### Sandbox Environment Variables
|
||||
Variables prefixed with `SANDBOX_ENV_` are passed through to the sandbox environment:
|
||||
|
||||
| Environment Variable | Description |
|
||||
|---------------------|-------------|
|
||||
| `SANDBOX_ENV_*` | Any variable with this prefix is passed to the sandbox (e.g., `SANDBOX_ENV_OPENAI_API_KEY`) |
|
||||
|
||||
## Security Configuration Variables
|
||||
|
||||
These variables correspond to the `[security]` section in `config.toml`:
|
||||
|
||||
| Environment Variable | Type | Default | Description |
|
||||
|---------------------|------|---------|-------------|
|
||||
| `SECURITY_CONFIRMATION_MODE` | boolean | `false` | Enable confirmation mode for actions |
|
||||
| `SECURITY_SECURITY_ANALYZER` | string | `"llm"` | Security analyzer to use (`llm`, `invariant`) |
|
||||
| `SECURITY_ENABLE_SECURITY_ANALYZER` | boolean | `true` | Enable security analysis |
|
||||
|
||||
## Debug and Logging Variables
|
||||
|
||||
| Environment Variable | Type | Default | Description |
|
||||
|---------------------|------|---------|-------------|
|
||||
| `DEBUG` | boolean | `false` | Enable general debug logging |
|
||||
| `DEBUG_LLM` | boolean | `false` | Enable LLM-specific debug logging |
|
||||
| `DEBUG_RUNTIME` | boolean | `false` | Enable runtime debug logging |
|
||||
| `LOG_TO_FILE` | boolean | auto | Log to file (auto-enabled when DEBUG=true) |
|
||||
|
||||
## Runtime-Specific Variables
|
||||
|
||||
### Docker Runtime
|
||||
| Environment Variable | Type | Default | Description |
|
||||
|---------------------|------|---------|-------------|
|
||||
| `SANDBOX_VOLUME_OVERLAYS` | string | `""` | Volume overlay configurations |
|
||||
|
||||
### Remote Runtime
|
||||
| Environment Variable | Type | Default | Description |
|
||||
|---------------------|------|---------|-------------|
|
||||
| `SANDBOX_API_KEY` | string | `""` | API key for remote runtime |
|
||||
| `SANDBOX_REMOTE_RUNTIME_API_URL` | string | `""` | Remote runtime API URL |
|
||||
|
||||
### Local Runtime
|
||||
| Environment Variable | Type | Default | Description |
|
||||
|---------------------|------|---------|-------------|
|
||||
| `RUNTIME_URL` | string | `""` | Runtime URL for local runtime |
|
||||
| `RUNTIME_URL_PATTERN` | string | `""` | Runtime URL pattern |
|
||||
| `RUNTIME_ID` | string | `""` | Runtime identifier |
|
||||
| `LOCAL_RUNTIME_MODE` | string | `""` | Enable local runtime mode (`1` to enable) |
|
||||
|
||||
## Integration Variables
|
||||
|
||||
### GitHub Integration
|
||||
| Environment Variable | Type | Default | Description |
|
||||
|---------------------|------|---------|-------------|
|
||||
| `GITHUB_TOKEN` | string | `""` | GitHub personal access token |
|
||||
|
||||
### Third-Party API Keys
|
||||
| Environment Variable | Type | Default | Description |
|
||||
|---------------------|------|---------|-------------|
|
||||
| `OPENAI_API_KEY` | string | `""` | OpenAI API key |
|
||||
| `ANTHROPIC_API_KEY` | string | `""` | Anthropic API key |
|
||||
| `GOOGLE_API_KEY` | string | `""` | Google API key |
|
||||
| `AZURE_API_KEY` | string | `""` | Azure API key |
|
||||
| `TAVILY_API_KEY` | string | `""` | Tavily search API key |
|
||||
|
||||
## Server Configuration Variables
|
||||
|
||||
These are primarily used when running OpenHands as a server:
|
||||
|
||||
| Environment Variable | Type | Default | Description |
|
||||
|---------------------|------|---------|-------------|
|
||||
| `FRONTEND_PORT` | integer | `3000` | Frontend server port |
|
||||
| `BACKEND_PORT` | integer | `8000` | Backend server port |
|
||||
| `FRONTEND_HOST` | string | `"localhost"` | Frontend host address |
|
||||
| `BACKEND_HOST` | string | `"localhost"` | Backend host address |
|
||||
| `WEB_HOST` | string | `"localhost"` | Web server host |
|
||||
| `SERVE_FRONTEND` | boolean | `true` | Whether to serve frontend |
|
||||
|
||||
## Deprecated Variables
|
||||
|
||||
These variables are deprecated and should be replaced:
|
||||
|
||||
| Environment Variable | Replacement | Description |
|
||||
|---------------------|-------------|-------------|
|
||||
| `WORKSPACE_BASE` | `SANDBOX_VOLUMES` | Use volume mounting instead |
|
||||
| `WORKSPACE_MOUNT_PATH` | `SANDBOX_VOLUMES` | Use volume mounting instead |
|
||||
| `WORKSPACE_MOUNT_PATH_IN_SANDBOX` | `SANDBOX_VOLUMES` | Use volume mounting instead |
|
||||
| `WORKSPACE_MOUNT_REWRITE` | `SANDBOX_VOLUMES` | Use volume mounting instead |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Setup with OpenAI
|
||||
```bash
|
||||
export LLM_MODEL="gpt-4o"
|
||||
export LLM_API_KEY="your-openai-api-key"
|
||||
export DEBUG=true
|
||||
```
|
||||
|
||||
### Docker Deployment with Custom Volumes
|
||||
```bash
|
||||
export RUNTIME="docker"
|
||||
export SANDBOX_VOLUMES="/host/workspace:/workspace:rw,/host/data:/data:ro"
|
||||
export SANDBOX_TIMEOUT=300
|
||||
```
|
||||
|
||||
### Remote Runtime Configuration
|
||||
```bash
|
||||
export RUNTIME="remote"
|
||||
export SANDBOX_API_KEY="your-remote-api-key"
|
||||
export SANDBOX_REMOTE_RUNTIME_API_URL="https://your-runtime-api.com"
|
||||
```
|
||||
|
||||
### Security-Enhanced Setup
|
||||
```bash
|
||||
export SECURITY_CONFIRMATION_MODE=true
|
||||
export SECURITY_SECURITY_ANALYZER="llm"
|
||||
export DEBUG_RUNTIME=true
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
1. **Boolean Values**: Environment variables expecting boolean values accept `true`/`false`, `1`/`0`, or `yes`/`no` (case-insensitive).
|
||||
|
||||
2. **List Values**: Lists should be provided as Python literal strings, e.g., `AGENT_DISABLED_MICROAGENTS='["microagent1", "microagent2"]'`.
|
||||
|
||||
3. **Dictionary Values**: Dictionaries should be provided as Python literal strings, e.g., `SANDBOX_RUNTIME_STARTUP_ENV_VARS='{"KEY": "value"}'`.
|
||||
|
||||
4. **Precedence**: Environment variables take precedence over TOML configuration files.
|
||||
|
||||
5. **Docker Usage**: When using Docker, pass environment variables with the `-e` flag:
|
||||
```bash
|
||||
docker run -e LLM_API_KEY="your-key" -e DEBUG=true openhands/openhands
|
||||
```
|
||||
|
||||
6. **Validation**: Invalid environment variable values will be logged as errors and fall back to defaults.
|
||||
@@ -25,6 +25,7 @@ from storage.billing_session import BillingSession
|
||||
from storage.database import session_maker
|
||||
from storage.saas_settings_store import SaasSettingsStore
|
||||
from storage.subscription_access import SubscriptionAccess
|
||||
from storage.user_settings import UserSettings
|
||||
|
||||
from openhands.server.user_auth import get_user_id
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
@@ -3,7 +3,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import React from "react";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { renderWithQueryAndI18n } from "test-utils";
|
||||
import { ConversationPanel } from "#/components/features/conversation-panel/conversation-panel";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import { Conversation } from "#/api/open-hands.types";
|
||||
|
||||
@@ -108,6 +108,16 @@ describe("InteractiveChatBox", () => {
|
||||
options,
|
||||
);
|
||||
|
||||
// Helper function to render with Router context
|
||||
const renderInteractiveChatBox = (props: any, options: any = {}) => {
|
||||
return renderWithProviders(
|
||||
<MemoryRouter>
|
||||
<InteractiveChatBox {...props} />
|
||||
</MemoryRouter>,
|
||||
options,
|
||||
);
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
global.URL.createObjectURL = vi
|
||||
.fn()
|
||||
|
||||
@@ -10,6 +10,19 @@ import {
|
||||
} from "#/mocks/handlers";
|
||||
import * as AdvancedSettingsUtlls from "#/utils/has-advanced-settings-set";
|
||||
import * as ToastHandlers from "#/utils/custom-toast-handlers";
|
||||
import BillingService from "#/api/billing-service/billing-service.api";
|
||||
|
||||
// Mock react-router hooks
|
||||
const mockUseSearchParams = vi.fn();
|
||||
vi.mock("react-router", () => ({
|
||||
useSearchParams: () => mockUseSearchParams(),
|
||||
}));
|
||||
|
||||
// Mock useIsAuthed hook
|
||||
const mockUseIsAuthed = vi.fn();
|
||||
vi.mock("#/hooks/query/use-is-authed", () => ({
|
||||
useIsAuthed: () => mockUseIsAuthed(),
|
||||
}));
|
||||
|
||||
// Mock react-router hooks
|
||||
const mockUseSearchParams = vi.fn();
|
||||
|
||||
@@ -28,6 +28,14 @@ vi.mock("#/state/security-analyzer-slice", () => ({
|
||||
appendSecurityAnalyzerInput: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("#/state/metrics-slice", () => ({
|
||||
setMetrics: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("#/state/security-analyzer-slice", () => ({
|
||||
appendSecurityAnalyzerInput: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("handleActionMessage", () => {
|
||||
beforeEach(() => {
|
||||
// Clear all mocks before each test
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Typography } from "#/ui/typography";
|
||||
|
||||
interface ResultSectionProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function ResultSection({ content }: ResultSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Typography.H3>{t("TASK_TRACKING_OBSERVATION$RESULT")}</Typography.H3>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-900 rounded-md overflow-auto text-gray-300 shadow-inner">
|
||||
<pre className="whitespace-pre-wrap text-sm">{content.trim()}</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,77 @@ interface AccountSettingsContextMenuProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SAAS_NAV_ITEMS = [
|
||||
{
|
||||
icon: <UserIcon width={16} height={16} />,
|
||||
to: "/settings/user",
|
||||
text: "COMMON$USER_SETTINGS",
|
||||
},
|
||||
{
|
||||
icon: <PuzzlePieceIcon width={16} height={16} />,
|
||||
to: "/settings/integrations",
|
||||
text: "SETTINGS$NAV_INTEGRATIONS",
|
||||
},
|
||||
{
|
||||
icon: <SettingsGearIcon width={16} height={16} />,
|
||||
to: "/settings/app",
|
||||
text: "COMMON$APPLICATION_SETTINGS",
|
||||
},
|
||||
{
|
||||
icon: <CircuitIcon width={16} height={16} />,
|
||||
to: "/settings",
|
||||
text: "COMMON$LANGUAGE_MODEL_LLM",
|
||||
},
|
||||
{
|
||||
icon: <CreditCardIcon width={16} height={16} />,
|
||||
to: "/settings/billing",
|
||||
text: "SETTINGS$NAV_BILLING",
|
||||
},
|
||||
{
|
||||
icon: <KeyIcon width={16} height={16} />,
|
||||
to: "/settings/secrets",
|
||||
text: "SETTINGS$NAV_SECRETS",
|
||||
},
|
||||
{
|
||||
icon: <KeyIcon width={16} height={16} />,
|
||||
to: "/settings/api-keys",
|
||||
text: "SETTINGS$NAV_API_KEYS",
|
||||
},
|
||||
{
|
||||
icon: <ServerProcessIcon width={16} height={16} />,
|
||||
to: "/settings/mcp",
|
||||
text: "SETTINGS$NAV_MCP",
|
||||
},
|
||||
];
|
||||
|
||||
const OSS_NAV_ITEMS = [
|
||||
{
|
||||
icon: <CircuitIcon width={16} height={16} />,
|
||||
to: "/settings",
|
||||
text: "COMMON$LANGUAGE_MODEL_LLM",
|
||||
},
|
||||
{
|
||||
icon: <ServerProcessIcon width={16} height={16} />,
|
||||
to: "/settings/mcp",
|
||||
text: "COMMON$MODEL_CONTEXT_PROTOCOL_MCP",
|
||||
},
|
||||
{
|
||||
icon: <PuzzlePieceIcon width={16} height={16} />,
|
||||
to: "/settings/integrations",
|
||||
text: "SETTINGS$NAV_INTEGRATIONS",
|
||||
},
|
||||
{
|
||||
icon: <SettingsGearIcon width={16} height={16} />,
|
||||
to: "/settings/app",
|
||||
text: "COMMON$APPLICATION_SETTINGS",
|
||||
},
|
||||
{
|
||||
icon: <KeyIcon width={16} height={16} />,
|
||||
to: "/settings/secrets",
|
||||
text: "SETTINGS$NAV_SECRETS",
|
||||
},
|
||||
];
|
||||
|
||||
export function AccountSettingsContextMenu({
|
||||
onLogout,
|
||||
onClose,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { useCreateStripeCheckoutSession } from "#/hooks/mutation/stripe/use-create-stripe-checkout-session";
|
||||
import { useBalance } from "#/hooks/query/use-balance";
|
||||
import { useSubscriptionAccess } from "#/hooks/query/use-subscription-access";
|
||||
import { cn } from "#/utils/utils";
|
||||
import MoneyIcon from "#/icons/money.svg?react";
|
||||
import { SettingsInput } from "../settings/settings-input";
|
||||
@@ -10,13 +11,24 @@ import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { amountIsValid } from "#/utils/amount-is-valid";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { PoweredByStripeTag } from "./powered-by-stripe-tag";
|
||||
import { CancelSubscriptionModal } from "./cancel-subscription-modal";
|
||||
|
||||
export function PaymentForm() {
|
||||
const { t } = useTranslation();
|
||||
const { data: balance, isLoading } = useBalance();
|
||||
const { data: subscriptionAccess } = useSubscriptionAccess();
|
||||
const { mutate: addBalance, isPending } = useCreateStripeCheckoutSession();
|
||||
|
||||
const [buttonIsDisabled, setButtonIsDisabled] = React.useState(true);
|
||||
const [showCancelModal, setShowCancelModal] = React.useState(false);
|
||||
|
||||
const subscriptionExpiredDate =
|
||||
subscriptionAccess?.end_at &&
|
||||
new Date(subscriptionAccess.end_at).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
const billingFormAction = async (formData: FormData) => {
|
||||
const amount = formData.get("top-up-input")?.toString();
|
||||
@@ -82,7 +94,50 @@ export function PaymentForm() {
|
||||
{isPending && <LoadingSpinner size="small" />}
|
||||
<PoweredByStripeTag />
|
||||
</div>
|
||||
|
||||
{/* Cancel Subscription Button or Cancellation Message */}
|
||||
{subscriptionAccess && (
|
||||
<div className="flex flex-col w-[680px] gap-2 mt-4">
|
||||
{subscriptionAccess.cancelled_at ? (
|
||||
<div className="text-red-500 text-sm">
|
||||
<Trans
|
||||
i18nKey={I18nKey.PAYMENT$SUBSCRIPTION_CANCELLED_EXPIRES}
|
||||
values={{ date: subscriptionExpiredDate }}
|
||||
components={{ date: <span className="underline" /> }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-4">
|
||||
<BrandButton
|
||||
testId="cancel-subscription-button"
|
||||
variant="ghost-danger"
|
||||
type="button"
|
||||
onClick={() => setShowCancelModal(true)}
|
||||
>
|
||||
{t(I18nKey.PAYMENT$CANCEL_SUBSCRIPTION)}
|
||||
</BrandButton>
|
||||
<div
|
||||
className="text-sm text-gray-300"
|
||||
data-testid="next-billing-date"
|
||||
>
|
||||
<Trans
|
||||
i18nKey={I18nKey.PAYMENT$NEXT_BILLING_DATE}
|
||||
values={{ date: subscriptionExpiredDate }}
|
||||
components={{ date: <span className="underline" /> }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cancel Subscription Modal */}
|
||||
<CancelSubscriptionModal
|
||||
isOpen={showCancelModal}
|
||||
onClose={() => setShowCancelModal(false)}
|
||||
endDate={subscriptionExpiredDate}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Terminal } from "@xterm/xterm";
|
||||
import React from "react";
|
||||
import { Command, useCommandStore } from "#/state/command-store";
|
||||
import { parseTerminalOutput } from "#/utils/parse-terminal-output";
|
||||
import { RootState } from "#/store";
|
||||
|
||||
/*
|
||||
NOTE: Tests for this hook are indirectly covered by the tests for the XTermTerminal component.
|
||||
|
||||
3
frontend/src/icons/jupyter-large.svg
Normal file
3
frontend/src/icons/jupyter-large.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="113" viewBox="0 0 113 113" fill="none">
|
||||
<path d="M57.9521 83.6306C57.2282 83.6659 56.3772 83.6871 55.5226 83.6871C41.334 83.6871 28.5015 77.8853 19.2672 68.5204L19.2602 68.5134C22.177 76.7765 27.3821 83.6271 34.1162 88.4967L34.2362 88.5814C40.8185 93.3522 49.0569 96.216 57.9627 96.216C66.8685 96.216 75.1034 93.3557 81.8022 88.5038L81.6821 88.585C88.5327 83.6306 93.7378 76.7835 96.5663 68.81L96.6546 68.5204C87.4062 77.8888 74.5631 83.6907 60.3675 83.6907C59.5164 83.6907 58.6689 83.6695 57.8285 83.6271L57.9521 83.6306ZM57.9486 24.9624C58.676 24.9236 59.527 24.9024 60.3851 24.9024C74.5702 24.9024 87.4027 30.7043 96.6334 40.0656L96.6405 40.0727C93.7237 31.8095 88.5186 24.9589 81.788 20.0893L81.668 20.0046C75.0857 15.2374 66.8473 12.3806 57.9415 12.3806C49.0357 12.3806 40.8008 15.2374 34.0985 20.0893L34.2186 20.0081C27.3644 24.9589 22.1593 31.8095 19.3308 39.7831L19.2425 40.0727C28.5015 30.7078 41.3517 24.9059 55.5579 24.9059C56.3983 24.9059 57.2388 24.9271 58.0686 24.966L57.9486 24.9624ZM25.5776 18.2672C25.5776 19.549 25.0585 20.7108 24.2216 21.5548C23.3882 22.3952 22.2335 22.9178 20.9587 22.9178C19.6839 22.9178 18.5257 22.3952 17.6958 21.5548C16.8589 20.7108 16.3398 19.5455 16.3398 18.2637C16.3398 16.9818 16.8589 15.8165 17.6958 14.9725C18.5292 14.1321 19.6839 13.6095 20.9587 13.6095C22.2335 13.6095 23.3918 14.1321 24.2251 14.9761C25.062 15.82 25.5776 16.9818 25.5776 18.2672ZM94.3734 9.84516C94.3734 9.84869 94.3734 9.85222 94.3734 9.85222C94.3734 11.5861 93.6742 13.1575 92.5442 14.2981C91.4178 15.4387 89.8534 16.1449 88.1266 16.1449C86.3998 16.1449 84.8355 15.4387 83.709 14.3016C82.579 13.1575 81.8798 11.5825 81.8798 9.84516C81.8798 8.10779 82.579 6.53285 83.709 5.38872C84.8355 4.25166 86.3963 3.54541 88.1266 3.54541C89.8569 3.54541 91.4178 4.25166 92.5442 5.38872C93.6742 6.52932 94.3734 8.10072 94.3734 9.8381V9.84516ZM35.1296 101.516C35.1296 101.516 35.1296 101.52 35.1296 101.523C35.1296 103.709 34.2503 105.69 32.8237 107.131C31.4042 108.568 29.4337 109.458 27.2585 109.458C25.0832 109.458 23.1128 108.568 21.6932 107.135C20.2666 105.694 19.3873 103.709 19.3873 101.52C19.3873 99.3306 20.2666 97.3495 21.6932 95.9053C23.1128 94.468 25.0797 93.5817 27.2585 93.5817C29.4372 93.5817 31.4042 94.4716 32.8237 95.9088C34.2468 97.3495 35.1296 99.3306 35.1296 101.516C35.1296 101.52 35.1296 101.52 35.1296 101.523V101.516Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -28,6 +28,11 @@ import { KeyStatusIcon } from "#/components/features/settings/key-status-icon";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { getProviderId } from "#/utils/map-provider";
|
||||
import { DEFAULT_OPENHANDS_MODEL } from "#/utils/verified-models";
|
||||
import { useSubscriptionAccess } from "#/hooks/query/use-subscription-access";
|
||||
import { UpgradeBannerWithBackdrop } from "#/components/features/settings/upgrade-banner-with-backdrop";
|
||||
import { useCreateSubscriptionCheckoutSession } from "#/hooks/mutation/stripe/use-create-subscription-checkout-session";
|
||||
import { useIsAuthed } from "#/hooks/query/use-is-authed";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface OpenHandsApiKeyHelpProps {
|
||||
testId: string;
|
||||
@@ -69,6 +74,10 @@ function LlmSettingsScreen() {
|
||||
const { data: resources } = useAIConfigOptions();
|
||||
const { data: settings, isLoading, isFetching } = useSettings();
|
||||
const { data: config } = useConfig();
|
||||
const { data: subscriptionAccess } = useSubscriptionAccess();
|
||||
const { data: isAuthed } = useIsAuthed();
|
||||
const { mutate: createSubscriptionCheckoutSession } =
|
||||
useCreateSubscriptionCheckoutSession();
|
||||
|
||||
const [view, setView] = React.useState<"basic" | "advanced">("basic");
|
||||
|
||||
@@ -440,7 +449,11 @@ function LlmSettingsScreen() {
|
||||
<div data-testid="llm-settings-screen" className="h-full relative">
|
||||
<form
|
||||
action={formAction}
|
||||
className="flex flex-col h-full justify-between"
|
||||
className={cn(
|
||||
"flex flex-col h-full justify-between",
|
||||
shouldShowUpgradeBanner && "h-[calc(100%-theme(spacing.12))]",
|
||||
)}
|
||||
inert={shouldShowUpgradeBanner}
|
||||
>
|
||||
<div className="flex flex-col gap-6">
|
||||
<SettingsSwitch
|
||||
@@ -448,6 +461,7 @@ function LlmSettingsScreen() {
|
||||
defaultIsToggled={view === "advanced"}
|
||||
onToggle={handleToggleAdvancedSettings}
|
||||
isToggled={view === "advanced"}
|
||||
isDisabled={shouldShowUpgradeBanner}
|
||||
>
|
||||
{t(I18nKey.SETTINGS$ADVANCED)}
|
||||
</SettingsSwitch>
|
||||
@@ -456,6 +470,7 @@ function LlmSettingsScreen() {
|
||||
<div
|
||||
data-testid="llm-settings-form-basic"
|
||||
className="flex flex-col gap-6"
|
||||
aria-disabled={shouldShowUpgradeBanner ? "true" : undefined}
|
||||
>
|
||||
{!isLoading && !isFetching && (
|
||||
<>
|
||||
@@ -480,6 +495,7 @@ function LlmSettingsScreen() {
|
||||
className="w-full max-w-[680px]"
|
||||
placeholder={settings.LLM_API_KEY_SET ? "<hidden>" : ""}
|
||||
onChange={handleApiKeyIsDirty}
|
||||
isDisabled={shouldShowUpgradeBanner}
|
||||
startContent={
|
||||
settings.LLM_API_KEY_SET && (
|
||||
<KeyStatusIcon isSet={settings.LLM_API_KEY_SET} />
|
||||
|
||||
@@ -99,6 +99,9 @@ describe("handleStatusMessage", () => {
|
||||
metadata: { msgId: "ERROR_ID" },
|
||||
});
|
||||
|
||||
// Verify that store.dispatch was not called
|
||||
expect(store.dispatch).not.toHaveBeenCalled();
|
||||
|
||||
// Verify that queryClient.invalidateQueries was not called
|
||||
expect(queryClient.invalidateQueries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
209
frontend/src/utils/__tests__/status.test.ts
Normal file
209
frontend/src/utils/__tests__/status.test.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getStatusCode, getIndicatorColor, IndicatorColor } from "../status";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
describe("getStatusCode", () => {
|
||||
it("should prioritize agent readiness over stale runtime status", () => {
|
||||
// Test case: Agent is ready (AWAITING_USER_INPUT) but runtime status is still starting
|
||||
const result = getStatusCode(
|
||||
{ id: "", message: "", type: "info", status_update: true }, // statusMessage
|
||||
"CONNECTED", // webSocketStatus
|
||||
"RUNNING", // conversationStatus
|
||||
"STATUS$STARTING_RUNTIME", // runtimeStatus (stale)
|
||||
AgentState.AWAITING_USER_INPUT, // agentState (ready)
|
||||
);
|
||||
|
||||
// Should return agent state message, not runtime status
|
||||
expect(result).toBe(I18nKey.AGENT_STATUS$WAITING_FOR_TASK);
|
||||
});
|
||||
|
||||
it("should show runtime status when agent is not ready", () => {
|
||||
// Test case: Agent is loading and runtime is starting
|
||||
const result = getStatusCode(
|
||||
{ id: "", message: "", type: "info", status_update: true }, // statusMessage
|
||||
"CONNECTED", // webSocketStatus
|
||||
"STARTING", // conversationStatus
|
||||
"STATUS$STARTING_RUNTIME", // runtimeStatus
|
||||
AgentState.LOADING, // agentState (not ready)
|
||||
);
|
||||
|
||||
// Should return runtime status since agent is not ready
|
||||
expect(result).toBe("STATUS$STARTING_RUNTIME");
|
||||
});
|
||||
|
||||
it("should handle agent running state with stale runtime status", () => {
|
||||
// Test case: Agent is running but runtime status is stale
|
||||
const result = getStatusCode(
|
||||
{ id: "", message: "", type: "info", status_update: true }, // statusMessage
|
||||
"CONNECTED", // webSocketStatus
|
||||
"RUNNING", // conversationStatus
|
||||
"STATUS$BUILDING_RUNTIME", // runtimeStatus (stale)
|
||||
AgentState.RUNNING, // agentState (ready)
|
||||
);
|
||||
|
||||
// Should return agent state message, not runtime status
|
||||
expect(result).toBe(I18nKey.AGENT_STATUS$RUNNING_TASK);
|
||||
});
|
||||
|
||||
it("should handle agent finished state with stale runtime status", () => {
|
||||
// Test case: Agent is finished but runtime status is stale
|
||||
const result = getStatusCode(
|
||||
{ id: "", message: "", type: "info", status_update: true }, // statusMessage
|
||||
"CONNECTED", // webSocketStatus
|
||||
"RUNNING", // conversationStatus
|
||||
"STATUS$SETTING_UP_WORKSPACE", // runtimeStatus (stale)
|
||||
AgentState.FINISHED, // agentState (ready)
|
||||
);
|
||||
|
||||
// Should return agent state message, not runtime status
|
||||
expect(result).toBe(I18nKey.AGENT_STATUS$WAITING_FOR_TASK);
|
||||
});
|
||||
|
||||
it("should still respect stopped states", () => {
|
||||
// Test case: Runtime is stopped - should always show stopped
|
||||
const result = getStatusCode(
|
||||
{ id: "", message: "", type: "info", status_update: true }, // statusMessage
|
||||
"CONNECTED", // webSocketStatus
|
||||
"STOPPED", // conversationStatus
|
||||
"STATUS$STOPPED", // runtimeStatus
|
||||
AgentState.RUNNING, // agentState
|
||||
);
|
||||
|
||||
// Should return stopped status regardless of agent state
|
||||
expect(result).toBe(I18nKey.CHAT_INTERFACE$STOPPED);
|
||||
});
|
||||
|
||||
it("should handle null agent state with runtime status", () => {
|
||||
// Test case: No agent state, runtime is starting
|
||||
const result = getStatusCode(
|
||||
{ id: "", message: "", type: "info", status_update: true }, // statusMessage
|
||||
"CONNECTED", // webSocketStatus
|
||||
"STARTING", // conversationStatus
|
||||
"STATUS$STARTING_RUNTIME", // runtimeStatus
|
||||
null, // agentState
|
||||
);
|
||||
|
||||
// Should return runtime status since no agent state
|
||||
expect(result).toBe("STATUS$STARTING_RUNTIME");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIndicatorColor", () => {
|
||||
it("should prioritize agent readiness over stale runtime status for AWAITING_USER_INPUT", () => {
|
||||
// Test case: Agent is ready (AWAITING_USER_INPUT) but runtime status is still starting
|
||||
const result = getIndicatorColor(
|
||||
"CONNECTED", // webSocketStatus
|
||||
"RUNNING", // conversationStatus
|
||||
"STATUS$STARTING_RUNTIME", // runtimeStatus (stale)
|
||||
AgentState.AWAITING_USER_INPUT, // agentState (ready)
|
||||
);
|
||||
|
||||
// Should return blue for AWAITING_USER_INPUT, not yellow for stale runtime
|
||||
expect(result).toBe(IndicatorColor.BLUE);
|
||||
});
|
||||
|
||||
it("should prioritize agent readiness over stale runtime status for RUNNING", () => {
|
||||
// Test case: Agent is running but runtime status is stale
|
||||
const result = getIndicatorColor(
|
||||
"CONNECTED", // webSocketStatus
|
||||
"RUNNING", // conversationStatus
|
||||
"STATUS$BUILDING_RUNTIME", // runtimeStatus (stale)
|
||||
AgentState.RUNNING, // agentState (ready)
|
||||
);
|
||||
|
||||
// Should return green for RUNNING, not yellow for stale runtime
|
||||
expect(result).toBe(IndicatorColor.GREEN);
|
||||
});
|
||||
|
||||
it("should prioritize agent readiness over stale runtime status for FINISHED", () => {
|
||||
// Test case: Agent is finished but runtime status is stale
|
||||
const result = getIndicatorColor(
|
||||
"CONNECTED", // webSocketStatus
|
||||
"RUNNING", // conversationStatus
|
||||
"STATUS$SETTING_UP_WORKSPACE", // runtimeStatus (stale)
|
||||
AgentState.FINISHED, // agentState (ready)
|
||||
);
|
||||
|
||||
// Should return green for FINISHED, not yellow for stale runtime
|
||||
expect(result).toBe(IndicatorColor.GREEN);
|
||||
});
|
||||
|
||||
it("should show yellow when agent is not ready and runtime is starting", () => {
|
||||
// Test case: Agent is loading and runtime is starting
|
||||
const result = getIndicatorColor(
|
||||
"CONNECTED", // webSocketStatus
|
||||
"STARTING", // conversationStatus
|
||||
"STATUS$STARTING_RUNTIME", // runtimeStatus
|
||||
AgentState.LOADING, // agentState (not ready)
|
||||
);
|
||||
|
||||
// Should return yellow since agent is not ready
|
||||
expect(result).toBe(IndicatorColor.YELLOW);
|
||||
});
|
||||
|
||||
it("should show orange for AWAITING_USER_CONFIRMATION even with stale runtime", () => {
|
||||
// Test case: Agent is awaiting confirmation but runtime status is stale
|
||||
const result = getIndicatorColor(
|
||||
"CONNECTED", // webSocketStatus
|
||||
"RUNNING", // conversationStatus
|
||||
"STATUS$STARTING_RUNTIME", // runtimeStatus (stale)
|
||||
AgentState.AWAITING_USER_CONFIRMATION, // agentState (ready)
|
||||
);
|
||||
|
||||
// Should return orange for AWAITING_USER_CONFIRMATION, not yellow for stale runtime
|
||||
expect(result).toBe(IndicatorColor.ORANGE);
|
||||
});
|
||||
|
||||
it("should still respect stopped states", () => {
|
||||
// Test case: Runtime is stopped - should always show red
|
||||
const result = getIndicatorColor(
|
||||
"CONNECTED", // webSocketStatus
|
||||
"STOPPED", // conversationStatus
|
||||
"STATUS$STOPPED", // runtimeStatus
|
||||
AgentState.RUNNING, // agentState
|
||||
);
|
||||
|
||||
// Should return red for stopped status regardless of agent state
|
||||
expect(result).toBe(IndicatorColor.RED);
|
||||
});
|
||||
|
||||
it("should handle null agent state with runtime status", () => {
|
||||
// Test case: No agent state, runtime is starting
|
||||
const result = getIndicatorColor(
|
||||
"CONNECTED", // webSocketStatus
|
||||
"STARTING", // conversationStatus
|
||||
"STATUS$STARTING_RUNTIME", // runtimeStatus
|
||||
null, // agentState
|
||||
);
|
||||
|
||||
// Should return yellow since no agent state and runtime is starting
|
||||
expect(result).toBe(IndicatorColor.YELLOW);
|
||||
});
|
||||
|
||||
it("should handle USER_CONFIRMED state with stale runtime status", () => {
|
||||
// Test case: Agent is in USER_CONFIRMED state but runtime status is stale
|
||||
const result = getIndicatorColor(
|
||||
"CONNECTED", // webSocketStatus
|
||||
"RUNNING", // conversationStatus
|
||||
"STATUS$BUILDING_RUNTIME", // runtimeStatus (stale)
|
||||
AgentState.USER_CONFIRMED, // agentState (ready)
|
||||
);
|
||||
|
||||
// Should return green for USER_CONFIRMED, not yellow for stale runtime
|
||||
expect(result).toBe(IndicatorColor.GREEN);
|
||||
});
|
||||
|
||||
it("should handle USER_REJECTED state with stale runtime status", () => {
|
||||
// Test case: Agent is in USER_REJECTED state but runtime status is stale
|
||||
const result = getIndicatorColor(
|
||||
"CONNECTED", // webSocketStatus
|
||||
"RUNNING", // conversationStatus
|
||||
"STATUS$BUILDING_RUNTIME", // runtimeStatus (stale)
|
||||
AgentState.USER_REJECTED, // agentState (ready)
|
||||
);
|
||||
|
||||
// Should return green for USER_REJECTED, not yellow for stale runtime
|
||||
expect(result).toBe(IndicatorColor.GREEN);
|
||||
});
|
||||
});
|
||||
@@ -59,6 +59,28 @@ export function renderWithProviders(
|
||||
return render(ui, { wrapper: Wrapper, ...renderOptions });
|
||||
}
|
||||
|
||||
// Export a render function for components that only need QueryClient and i18next providers
|
||||
// (without Redux store)
|
||||
export function renderWithQueryAndI18n(
|
||||
ui: React.ReactElement,
|
||||
renderOptions: Omit<RenderOptions, "wrapper"> = {},
|
||||
) {
|
||||
function Wrapper({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<QueryClientProvider
|
||||
client={
|
||||
new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
})
|
||||
}
|
||||
>
|
||||
<I18nextProvider i18n={i18n}>{children}</I18nextProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
return render(ui, { wrapper: Wrapper, ...renderOptions });
|
||||
}
|
||||
|
||||
export const createAxiosNotFoundErrorObject = () =>
|
||||
new AxiosError(
|
||||
"Request failed with status code 404",
|
||||
|
||||
110
openhands-cli/openhands-cli.spec
Normal file
110
openhands-cli/openhands-cli.spec
Normal file
@@ -0,0 +1,110 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
"""
|
||||
PyInstaller spec file for OpenHands CLI.
|
||||
|
||||
This spec file configures PyInstaller to create a standalone executable
|
||||
for the OpenHands CLI application.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import os
|
||||
import sys
|
||||
from PyInstaller.utils.hooks import (
|
||||
collect_submodules,
|
||||
collect_data_files,
|
||||
copy_metadata
|
||||
)
|
||||
|
||||
|
||||
|
||||
# Get the project root directory (current working directory when running PyInstaller)
|
||||
project_root = Path.cwd()
|
||||
|
||||
a = Analysis(
|
||||
['openhands_cli/simple_main.py'],
|
||||
pathex=[str(project_root)],
|
||||
binaries=[],
|
||||
datas=[
|
||||
# Include any data files that might be needed
|
||||
# Add more data files here if needed in the future
|
||||
*collect_data_files('tiktoken'),
|
||||
*collect_data_files('tiktoken_ext'),
|
||||
*collect_data_files('litellm'),
|
||||
*collect_data_files('fastmcp'),
|
||||
*collect_data_files('mcp'),
|
||||
# Include Jinja prompt templates required by the agent SDK
|
||||
*collect_data_files('openhands.sdk.agent', includes=['prompts/*.j2']),
|
||||
# Include package metadata for importlib.metadata
|
||||
*copy_metadata('fastmcp'),
|
||||
],
|
||||
hiddenimports=[
|
||||
# Explicitly include modules that might not be detected automatically
|
||||
*collect_submodules('openhands_cli'),
|
||||
*collect_submodules('prompt_toolkit'),
|
||||
# Include OpenHands SDK submodules explicitly to avoid resolution issues
|
||||
*collect_submodules('openhands.sdk'),
|
||||
*collect_submodules('openhands.tools'),
|
||||
*collect_submodules('tiktoken'),
|
||||
*collect_submodules('tiktoken_ext'),
|
||||
*collect_submodules('litellm'),
|
||||
*collect_submodules('fastmcp'),
|
||||
# Include mcp but exclude CLI parts that require typer
|
||||
'mcp.types',
|
||||
'mcp.client',
|
||||
'mcp.server',
|
||||
'mcp.shared',
|
||||
'openhands.tools.execute_bash',
|
||||
'openhands.tools.str_replace_editor',
|
||||
'openhands.tools.task_tracker',
|
||||
],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
# runtime_hooks=[str(project_root / "hooks" / "rthook_profile_imports.py")],
|
||||
excludes=[
|
||||
# Exclude unnecessary modules to reduce binary size
|
||||
'tkinter',
|
||||
'matplotlib',
|
||||
'numpy',
|
||||
'scipy',
|
||||
'pandas',
|
||||
'IPython',
|
||||
'jupyter',
|
||||
'notebook',
|
||||
# Exclude mcp CLI parts that cause issues
|
||||
'mcp.cli',
|
||||
'prompt_toolkit.contrib.ssh',
|
||||
'fastmcp.cli',
|
||||
'boto3',
|
||||
'botocore',
|
||||
'posthog',
|
||||
'browser-use',
|
||||
'openhands.tools.browser_use'
|
||||
],
|
||||
noarchive=False,
|
||||
# IMPORTANT: do not use optimize=2 (-OO) because it strips docstrings used by PLY/bashlex grammar
|
||||
optimize=0,
|
||||
)
|
||||
pyz = PYZ(a.pure)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
[],
|
||||
name='openhands-cli',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=True, # Strip debug symbols to reduce size
|
||||
upx=True, # Use UPX compression if available
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=True, # CLI application needs console
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
icon=None, # Add icon path here if you have one
|
||||
)
|
||||
@@ -31,6 +31,7 @@ from openhands_cli.tui.tui import (
|
||||
)
|
||||
from openhands_cli.user_actions import UserConfirmation, exit_session_confirmation
|
||||
from openhands_cli.user_actions.utils import get_session_prompter
|
||||
from openhands_cli.conversation_manager import ConversationManager
|
||||
|
||||
|
||||
def _restore_tty() -> None:
|
||||
@@ -98,6 +99,7 @@ def run_cli_entry(resume_conversation_id: str | None = None) -> None:
|
||||
# Create conversation runner to handle state machine logic
|
||||
runner = None
|
||||
session = get_session_prompter()
|
||||
conversation_manager = ConversationManager()
|
||||
|
||||
# Main chat loop
|
||||
while True:
|
||||
@@ -196,6 +198,25 @@ def run_cli_entry(resume_conversation_id: str | None = None) -> None:
|
||||
# Resume without new message
|
||||
message = None
|
||||
|
||||
elif command == '/list':
|
||||
conversation_manager.list_conversations()
|
||||
continue
|
||||
|
||||
elif command.startswith('/load '):
|
||||
conversation_id_arg = command[6:].strip() # Remove "/load "
|
||||
if not conversation_id_arg:
|
||||
print_formatted_text(HTML('<red>Please specify a conversation ID.</red>'))
|
||||
print_formatted_text(HTML('<grey>Usage: /load <conversation_id></grey>'))
|
||||
continue
|
||||
|
||||
# Attempt to load the conversation
|
||||
loaded_conversation = conversation_manager.load_conversation(conversation_id_arg)
|
||||
if loaded_conversation:
|
||||
# If we successfully loaded a conversation, we would switch to it here
|
||||
# For now, this is a placeholder for future enhancement
|
||||
pass
|
||||
continue
|
||||
|
||||
if not runner or not conversation:
|
||||
conversation = setup_conversation(conversation_id)
|
||||
runner = ConversationRunner(conversation)
|
||||
|
||||
464
openhands-cli/openhands_cli/conversation_manager.py
Normal file
464
openhands-cli/openhands_cli/conversation_manager.py
Normal file
@@ -0,0 +1,464 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Conversation management functionality for OpenHands CLI.
|
||||
Handles listing, loading, and managing past conversations.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
from uuid import UUID
|
||||
|
||||
try:
|
||||
from openhands.sdk import BaseConversation, Conversation, LocalFileStore
|
||||
from openhands.storage.data_models.conversation_metadata import ConversationMetadata
|
||||
SDK_AVAILABLE = True
|
||||
except ImportError:
|
||||
# For testing or when SDK is not available
|
||||
BaseConversation = None
|
||||
Conversation = None
|
||||
LocalFileStore = None
|
||||
ConversationMetadata = None
|
||||
SDK_AVAILABLE = False
|
||||
from prompt_toolkit import print_formatted_text
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
|
||||
from openhands_cli.locations import PERSISTENCE_DIR
|
||||
|
||||
|
||||
class ConversationInfo:
|
||||
"""Information about a stored conversation."""
|
||||
|
||||
def __init__(self, conversation_id: str, created_at: datetime, title: Optional[str] = None,
|
||||
last_updated_at: Optional[datetime] = None):
|
||||
self.conversation_id = conversation_id
|
||||
self.created_at = created_at
|
||||
self.title = title
|
||||
self.last_updated_at = last_updated_at or created_at
|
||||
|
||||
@property
|
||||
def short_id(self) -> str:
|
||||
"""Return a shortened version of the conversation ID for display."""
|
||||
return self.conversation_id[:8]
|
||||
|
||||
def format_date(self, date: datetime) -> str:
|
||||
"""Format a datetime for display."""
|
||||
now = datetime.now(date.tzinfo)
|
||||
diff = now - date
|
||||
|
||||
if diff.days == 0:
|
||||
if diff.seconds < 3600: # Less than 1 hour
|
||||
minutes = diff.seconds // 60
|
||||
return f"{minutes}m ago"
|
||||
else: # Less than 1 day
|
||||
hours = diff.seconds // 3600
|
||||
return f"{hours}h ago"
|
||||
elif diff.days == 1:
|
||||
return "1 day ago"
|
||||
elif diff.days < 7:
|
||||
return f"{diff.days} days ago"
|
||||
else:
|
||||
return date.strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
class ConversationManager:
|
||||
"""Manages conversation listing and loading for the CLI."""
|
||||
|
||||
def __init__(self):
|
||||
self.conversations_dir = os.path.join(PERSISTENCE_DIR, "conversation")
|
||||
|
||||
def discover_conversations(self) -> List[ConversationInfo]:
|
||||
"""Discover all stored conversations."""
|
||||
conversations = []
|
||||
|
||||
if not os.path.exists(self.conversations_dir):
|
||||
return conversations
|
||||
|
||||
for item in os.listdir(self.conversations_dir):
|
||||
conversation_path = os.path.join(self.conversations_dir, item)
|
||||
if os.path.isdir(conversation_path):
|
||||
try:
|
||||
# Try to parse as UUID to validate it's a conversation directory
|
||||
UUID(item)
|
||||
|
||||
# Look for metadata or conversation files
|
||||
metadata_file = os.path.join(conversation_path, "metadata.json")
|
||||
conversation_file = os.path.join(conversation_path, "conversation.json")
|
||||
|
||||
created_at = datetime.fromtimestamp(os.path.getctime(conversation_path))
|
||||
last_updated_at = datetime.fromtimestamp(os.path.getmtime(conversation_path))
|
||||
title = None
|
||||
|
||||
# Try to extract title from metadata if available
|
||||
if os.path.exists(metadata_file):
|
||||
try:
|
||||
with open(metadata_file, 'r') as f:
|
||||
metadata = json.load(f)
|
||||
title = metadata.get('title')
|
||||
if 'created_at' in metadata:
|
||||
created_at = datetime.fromisoformat(metadata['created_at'].replace('Z', '+00:00'))
|
||||
if 'last_updated_at' in metadata:
|
||||
last_updated_at = datetime.fromisoformat(metadata['last_updated_at'].replace('Z', '+00:00'))
|
||||
except (json.JSONDecodeError, ValueError, KeyError):
|
||||
pass # Use default values
|
||||
|
||||
# If no title from metadata, try to extract from first user message
|
||||
if not title and os.path.exists(conversation_file):
|
||||
title = self._extract_title_from_conversation(conversation_file)
|
||||
|
||||
conversations.append(ConversationInfo(
|
||||
conversation_id=item,
|
||||
created_at=created_at,
|
||||
title=title,
|
||||
last_updated_at=last_updated_at
|
||||
))
|
||||
|
||||
except ValueError:
|
||||
# Not a valid UUID, skip
|
||||
continue
|
||||
except Exception:
|
||||
# Other errors, skip this conversation
|
||||
continue
|
||||
|
||||
# Sort by last updated time, most recent first
|
||||
conversations.sort(key=lambda c: c.last_updated_at, reverse=True)
|
||||
return conversations
|
||||
|
||||
def _extract_title_from_conversation(self, conversation_file: str) -> Optional[str]:
|
||||
"""Extract a title from the first user message in the conversation."""
|
||||
try:
|
||||
with open(conversation_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Look for the first user message
|
||||
events = data.get('events', [])
|
||||
for event in events:
|
||||
if event.get('source') == 'user' and event.get('message'):
|
||||
message = event['message']
|
||||
# Take first 50 characters as title
|
||||
if len(message) > 50:
|
||||
return message[:47] + "..."
|
||||
return message
|
||||
|
||||
except (json.JSONDecodeError, KeyError, FileNotFoundError):
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def list_conversations(self, limit: int = 10) -> None:
|
||||
"""Display a list of past conversations."""
|
||||
conversations = self.discover_conversations()
|
||||
|
||||
if not conversations:
|
||||
print_formatted_text(HTML("<yellow>No past conversations found.</yellow>"))
|
||||
print_formatted_text(HTML("<grey>Start a new conversation by typing a message!</grey>"))
|
||||
return
|
||||
|
||||
print_formatted_text(HTML("<gold>📚 Past Conversations</gold>"))
|
||||
print_formatted_text("")
|
||||
|
||||
# Show up to the specified limit
|
||||
for i, conv in enumerate(conversations[:limit]):
|
||||
if i >= limit:
|
||||
break
|
||||
|
||||
# Format the display
|
||||
title_display = conv.title or "<untitled>"
|
||||
if len(title_display) > 60:
|
||||
title_display = title_display[:57] + "..."
|
||||
|
||||
created_display = conv.format_date(conv.created_at)
|
||||
updated_display = conv.format_date(conv.last_updated_at)
|
||||
|
||||
print_formatted_text(HTML(
|
||||
f" <white>{conv.short_id}</white> - {title_display}"
|
||||
))
|
||||
print_formatted_text(HTML(
|
||||
f" <grey>Created: {created_display}, Updated: {updated_display}</grey>"
|
||||
))
|
||||
print_formatted_text("")
|
||||
|
||||
if len(conversations) > limit:
|
||||
remaining = len(conversations) - limit
|
||||
print_formatted_text(HTML(f"<grey>... and {remaining} more conversations</grey>"))
|
||||
print_formatted_text("")
|
||||
|
||||
print_formatted_text(HTML("<grey>Use '/load <id>' to resume a conversation</grey>"))
|
||||
print_formatted_text("")
|
||||
|
||||
def load_conversation(self, conversation_id: str) -> Optional[object]:
|
||||
"""Load a conversation by ID (supports both full UUID and short ID)."""
|
||||
conversations = self.discover_conversations()
|
||||
|
||||
# Find matching conversation (support both full and short ID)
|
||||
matching_conv = None
|
||||
for conv in conversations:
|
||||
if conv.conversation_id == conversation_id or conv.conversation_id.startswith(conversation_id):
|
||||
matching_conv = conv
|
||||
break
|
||||
|
||||
if not matching_conv:
|
||||
print_formatted_text(HTML(f"<red>Conversation '{conversation_id}' not found.</red>"))
|
||||
print_formatted_text(HTML("<grey>Use '/list' to see available conversations.</grey>"))
|
||||
return None
|
||||
|
||||
try:
|
||||
# Load the conversation using the SDK
|
||||
conversation_uuid = UUID(matching_conv.conversation_id)
|
||||
conversation_path = os.path.join(self.conversations_dir, matching_conv.conversation_id)
|
||||
|
||||
# We need to reconstruct the conversation with the agent
|
||||
# For now, we'll return None and let the caller handle creating a new conversation
|
||||
# This is a limitation that would need agent configuration to fully implement
|
||||
print_formatted_text(HTML(f"<yellow>Found conversation {matching_conv.short_id}</yellow>"))
|
||||
print_formatted_text(HTML(f"<grey>Title: {matching_conv.title or 'Untitled'}</grey>"))
|
||||
print_formatted_text(HTML(f"<grey>Created: {matching_conv.format_date(matching_conv.created_at)}</grey>"))
|
||||
print_formatted_text(HTML("<red>Note: Full conversation loading requires agent reconfiguration.</red>"))
|
||||
print_formatted_text(HTML("<grey>This feature will be enhanced in a future update.</grey>"))
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print_formatted_text(HTML(f"<red>Error loading conversation: {e}</red>"))
|
||||
return None
|
||||
|
||||
def get_conversation_suggestions(self, partial_id: str) -> List[str]:
|
||||
"""Get conversation ID suggestions for command completion."""
|
||||
conversations = self.discover_conversations()
|
||||
suggestions = []
|
||||
|
||||
for conv in conversations:
|
||||
# Add both short and full IDs if they match the partial input
|
||||
if conv.conversation_id.startswith(partial_id):
|
||||
suggestions.append(conv.conversation_id)
|
||||
if conv.short_id.startswith(partial_id) and conv.short_id not in suggestions:
|
||||
suggestions.append(conv.short_id)
|
||||
|
||||
return suggestions[:10] # Limit to 10 suggestions
|
||||
|
||||
def view_conversation(
|
||||
self,
|
||||
conversation_id: str,
|
||||
event_filter: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> None:
|
||||
"""View conversation content with optional event filtering.
|
||||
|
||||
Args:
|
||||
conversation_id: Full or short conversation ID
|
||||
event_filter: Filter events by type (action, observation, user, agent, etc.)
|
||||
limit: Maximum number of events to display
|
||||
offset: Number of events to skip (for pagination)
|
||||
"""
|
||||
conversations = self.discover_conversations()
|
||||
|
||||
# Find matching conversation
|
||||
target_conv = None
|
||||
for conv in conversations:
|
||||
if (conv.conversation_id == conversation_id or
|
||||
conv.short_id == conversation_id or
|
||||
conv.conversation_id.startswith(conversation_id)):
|
||||
target_conv = conv
|
||||
break
|
||||
|
||||
if not target_conv:
|
||||
print_formatted_text(HTML(f"<red>Conversation '{conversation_id}' not found</red>"))
|
||||
return
|
||||
|
||||
# Load conversation events
|
||||
events = self._load_conversation_events(target_conv.conversation_id)
|
||||
if not events:
|
||||
print_formatted_text(HTML(f"<yellow>No events found in conversation {target_conv.short_id}</yellow>"))
|
||||
return
|
||||
|
||||
# Apply filtering
|
||||
if event_filter:
|
||||
events = self._filter_events(events, event_filter)
|
||||
|
||||
# Apply pagination
|
||||
total_events = len(events)
|
||||
paginated_events = events[offset:offset + limit]
|
||||
|
||||
# Display conversation header
|
||||
self._display_conversation_header(target_conv, total_events, len(paginated_events), offset, event_filter)
|
||||
|
||||
# Display events
|
||||
for i, event in enumerate(paginated_events, start=offset + 1):
|
||||
self._display_event(event, i)
|
||||
|
||||
# Display pagination info
|
||||
if total_events > offset + limit:
|
||||
remaining = total_events - (offset + limit)
|
||||
print_formatted_text(HTML(f"<grey>... and {remaining} more events. Use '/view {conversation_id} --offset {offset + limit}' to see more</grey>"))
|
||||
|
||||
def _load_conversation_events(self, conversation_id: str) -> List[Dict]:
|
||||
"""Load events from conversation storage."""
|
||||
conversation_dir = Path(self.conversations_dir) / conversation_id
|
||||
events_file = conversation_dir / "events.jsonl"
|
||||
|
||||
if not events_file.exists():
|
||||
return []
|
||||
|
||||
events = []
|
||||
try:
|
||||
with open(events_file, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
try:
|
||||
event = json.loads(line)
|
||||
events.append(event)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
except Exception as e:
|
||||
print_formatted_text(HTML(f"<red>Error loading events: {e}</red>"))
|
||||
return []
|
||||
|
||||
return events
|
||||
|
||||
def _filter_events(self, events: List[Dict], event_filter: str) -> List[Dict]:
|
||||
"""Filter events by type or source."""
|
||||
filtered = []
|
||||
filter_lower = event_filter.lower()
|
||||
|
||||
for event in events:
|
||||
event_type = event.get('event_type', '').lower()
|
||||
source = event.get('source', '').lower()
|
||||
|
||||
# Check various filter criteria
|
||||
if (filter_lower in event_type or
|
||||
filter_lower in source or
|
||||
self._matches_event_category(event, filter_lower)):
|
||||
filtered.append(event)
|
||||
|
||||
return filtered
|
||||
|
||||
def _matches_event_category(self, event: Dict, filter_term: str) -> bool:
|
||||
"""Check if event matches category filters."""
|
||||
event_type = event.get('event_type', '').lower()
|
||||
|
||||
# Category mappings
|
||||
action_types = ['cmdrunaction', 'fileeditaction', 'filereadaction', 'filewriteaction',
|
||||
'browseinteractiveaction', 'browseurlaction', 'messageaction',
|
||||
'agentthinkaction', 'ipythonruncellaction', 'mcpaction']
|
||||
|
||||
observation_types = ['cmdoutputobservation', 'fileeditobservation', 'filereadobservation',
|
||||
'filewriteobservation', 'browseroutputobservation', 'errorobservation',
|
||||
'ipythonruncellobservation', 'mcpobservation']
|
||||
|
||||
if filter_term == 'action' and any(action in event_type for action in action_types):
|
||||
return True
|
||||
elif filter_term == 'observation' and any(obs in event_type for obs in observation_types):
|
||||
return True
|
||||
elif filter_term == 'command' and 'cmdrun' in event_type:
|
||||
return True
|
||||
elif filter_term == 'file' and any(file_op in event_type for file_op in ['fileedit', 'fileread', 'filewrite']):
|
||||
return True
|
||||
elif filter_term == 'browse' and 'browse' in event_type:
|
||||
return True
|
||||
elif filter_term == 'message' and 'message' in event_type:
|
||||
return True
|
||||
elif filter_term == 'think' and 'think' in event_type:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _display_conversation_header(self, conv: ConversationInfo, total: int, shown: int, offset: int, filter_type: Optional[str]):
|
||||
"""Display conversation header with metadata."""
|
||||
print_formatted_text("")
|
||||
print_formatted_text(HTML(f"<bold>Conversation: {conv.title}</bold>"))
|
||||
print_formatted_text(HTML(f"<grey>ID: {conv.short_id} ({conv.conversation_id})</grey>"))
|
||||
print_formatted_text(HTML(f"<grey>Modified: {conv.format_date()}</grey>"))
|
||||
|
||||
if filter_type:
|
||||
print_formatted_text(HTML(f"<grey>Filter: {filter_type} | Showing {shown} of {total} events (offset: {offset})</grey>"))
|
||||
else:
|
||||
print_formatted_text(HTML(f"<grey>Showing {shown} of {total} events (offset: {offset})</grey>"))
|
||||
|
||||
print_formatted_text(HTML("<grey>" + "─" * 80 + "</grey>"))
|
||||
|
||||
def _display_event(self, event: Dict, index: int):
|
||||
"""Display a single event with formatting."""
|
||||
event_type = event.get('event_type', 'Unknown')
|
||||
source = event.get('source', 'unknown')
|
||||
timestamp = event.get('timestamp', '')
|
||||
|
||||
# Format timestamp
|
||||
time_str = ""
|
||||
if timestamp:
|
||||
try:
|
||||
if isinstance(timestamp, (int, float)):
|
||||
dt = datetime.fromtimestamp(timestamp)
|
||||
time_str = dt.strftime("%H:%M:%S")
|
||||
elif isinstance(timestamp, str):
|
||||
# Try to parse ISO format
|
||||
dt = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
|
||||
time_str = dt.strftime("%H:%M:%S")
|
||||
except:
|
||||
time_str = str(timestamp)[:8] # Fallback
|
||||
|
||||
# Color coding by event type and source
|
||||
if 'action' in event_type.lower():
|
||||
type_color = 'blue'
|
||||
icon = '→'
|
||||
elif 'observation' in event_type.lower():
|
||||
type_color = 'green'
|
||||
icon = '←'
|
||||
else:
|
||||
type_color = 'white'
|
||||
icon = '•'
|
||||
|
||||
# Source color
|
||||
source_color = 'cyan' if source == 'user' else 'yellow' if source == 'agent' else 'white'
|
||||
|
||||
# Display event header
|
||||
print_formatted_text(HTML(f"<grey>[{index:3d}]</grey> <{type_color}>{icon} {event_type}</{type_color}> <{source_color}>({source})</{source_color}> <grey>{time_str}</grey>"))
|
||||
|
||||
# Display event content
|
||||
content = self._extract_event_content(event)
|
||||
if content:
|
||||
# Truncate long content
|
||||
if len(content) > 200:
|
||||
content = content[:197] + "..."
|
||||
|
||||
# Indent content
|
||||
for line in content.split('\n'):
|
||||
if line.strip():
|
||||
print_formatted_text(HTML(f" <white>{line}</white>"))
|
||||
|
||||
print_formatted_text("") # Empty line between events
|
||||
|
||||
def _extract_event_content(self, event: Dict) -> str:
|
||||
"""Extract meaningful content from an event."""
|
||||
# Try different content fields
|
||||
content_fields = ['content', 'message', 'command', 'path', 'text', 'output', 'thought']
|
||||
|
||||
for field in content_fields:
|
||||
if field in event and event[field]:
|
||||
return str(event[field]).strip()
|
||||
|
||||
# For complex events, try to extract key information
|
||||
if 'args' in event and isinstance(event['args'], dict):
|
||||
args = event['args']
|
||||
for field in content_fields:
|
||||
if field in args and args[field]:
|
||||
return str(args[field]).strip()
|
||||
|
||||
return ""
|
||||
|
||||
def get_available_filters(self) -> List[str]:
|
||||
"""Get list of available event filters."""
|
||||
return [
|
||||
'action', # All action events
|
||||
'observation', # All observation events
|
||||
'user', # Events from user
|
||||
'agent', # Events from agent
|
||||
'command', # Command execution events
|
||||
'file', # File operation events
|
||||
'browse', # Browser events
|
||||
'message', # Message events
|
||||
'think', # Agent thinking events
|
||||
]
|
||||
172
openhands-cli/openhands_cli/fast_help.py
Normal file
172
openhands-cli/openhands_cli/fast_help.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""Fast help module for OpenHands CLI.
|
||||
|
||||
This module provides a lightweight implementation of the CLI help and version commands
|
||||
without loading all the dependencies, which significantly improves the
|
||||
performance of `openhands --help` and `openhands --version`.
|
||||
|
||||
The approach is to create a simplified version of the CLI parser that only includes
|
||||
the necessary options for displaying help and version information. This avoids loading
|
||||
the full OpenHands codebase, which can take several seconds.
|
||||
|
||||
This implementation addresses GitHub issue #10698, which reported that
|
||||
`openhands --help` was taking around 20 seconds to run.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
|
||||
def get_fast_cli_parser() -> argparse.ArgumentParser:
|
||||
"""Create a lightweight argument parser for CLI help command."""
|
||||
# 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,
|
||||
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 with common arguments
|
||||
cli_parser = subparsers.add_parser(
|
||||
'cli', help='Run OpenHands in CLI mode (terminal interface)'
|
||||
)
|
||||
|
||||
# Add common arguments
|
||||
cli_parser.add_argument(
|
||||
'--config-file',
|
||||
type=str,
|
||||
default='config.toml',
|
||||
help='Path to the config file (default: config.toml in the current directory)',
|
||||
)
|
||||
cli_parser.add_argument(
|
||||
'-t',
|
||||
'--task',
|
||||
type=str,
|
||||
default='',
|
||||
help='The task for the agent to perform',
|
||||
)
|
||||
cli_parser.add_argument(
|
||||
'-f',
|
||||
'--file',
|
||||
type=str,
|
||||
help='Path to a file containing the task. Overrides -t if both are provided.',
|
||||
)
|
||||
cli_parser.add_argument(
|
||||
'-n',
|
||||
'--name',
|
||||
help='Session name',
|
||||
type=str,
|
||||
default='',
|
||||
)
|
||||
cli_parser.add_argument(
|
||||
'--log-level',
|
||||
help='Set the log level',
|
||||
type=str,
|
||||
default=None,
|
||||
)
|
||||
cli_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',
|
||||
)
|
||||
cli_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',
|
||||
)
|
||||
cli_parser.add_argument(
|
||||
'-v', '--version', action='store_true', help='Show version information'
|
||||
)
|
||||
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_fast_subparser(
|
||||
parser: argparse.ArgumentParser, name: str
|
||||
) -> argparse.ArgumentParser:
|
||||
"""Get a subparser by name."""
|
||||
for action in parser._actions:
|
||||
if isinstance(action, argparse._SubParsersAction):
|
||||
if name in action.choices:
|
||||
return action.choices[name]
|
||||
raise ValueError(f"Subparser '{name}' not found")
|
||||
|
||||
|
||||
def handle_fast_commands() -> bool:
|
||||
"""Handle fast path commands like help and version.
|
||||
|
||||
Returns:
|
||||
bool: True if a command was handled, False otherwise.
|
||||
"""
|
||||
# Handle --help or -h
|
||||
if len(sys.argv) == 2 and sys.argv[1] in ('--help', '-h'):
|
||||
parser = get_fast_cli_parser()
|
||||
|
||||
# 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_fast_subparser(parser, 'cli')
|
||||
print(cli_parser.format_help())
|
||||
|
||||
return True
|
||||
|
||||
# Handle --version or -v
|
||||
if len(sys.argv) == 2 and sys.argv[1] in ('--version', '-v'):
|
||||
import openhands
|
||||
|
||||
print(f'OpenHands CLI version: {openhands.get_version()}')
|
||||
return True
|
||||
|
||||
return False
|
||||
61
openhands-cli/openhands_cli/listeners/loading_listener.py
Normal file
61
openhands-cli/openhands_cli/listeners/loading_listener.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
Loading animation utilities for OpenHands CLI.
|
||||
Provides animated loading screens during agent initialization.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
def display_initialization_animation(text: str, is_loaded: threading.Event) -> None:
|
||||
"""Display a spinning animation while agent is being initialized.
|
||||
|
||||
Args:
|
||||
text: The text to display alongside the animation
|
||||
is_loaded: Threading event that signals when loading is complete
|
||||
"""
|
||||
ANIMATION_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
||||
|
||||
i = 0
|
||||
while not is_loaded.is_set():
|
||||
sys.stdout.write('\n')
|
||||
sys.stdout.write(
|
||||
f'\033[s\033[J\033[38;2;255;215;0m[{ANIMATION_FRAMES[i % len(ANIMATION_FRAMES)]}] {text}\033[0m\033[u\033[1A'
|
||||
)
|
||||
sys.stdout.flush()
|
||||
time.sleep(0.1)
|
||||
i += 1
|
||||
|
||||
sys.stdout.write('\r' + ' ' * (len(text) + 10) + '\r')
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
|
||||
class LoadingContext:
|
||||
"""Context manager for displaying loading animations in a separate thread."""
|
||||
|
||||
def __init__(self, text: str):
|
||||
"""Initialize the loading context.
|
||||
|
||||
Args:
|
||||
text: The text to display during loading
|
||||
"""
|
||||
self.text = text
|
||||
self.is_loaded = threading.Event()
|
||||
self.loading_thread: threading.Thread | None = None
|
||||
|
||||
def __enter__(self) -> 'LoadingContext':
|
||||
"""Start the loading animation in a separate thread."""
|
||||
self.loading_thread = threading.Thread(
|
||||
target=display_initialization_animation,
|
||||
args=(self.text, self.is_loaded),
|
||||
daemon=True
|
||||
)
|
||||
self.loading_thread.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
"""Stop the loading animation and clean up the thread."""
|
||||
self.is_loaded.set()
|
||||
if self.loading_thread:
|
||||
self.loading_thread.join(timeout=1.0) # Wait up to 1 second for thread to finish
|
||||
55
openhands-cli/openhands_cli/llm_utils.py
Normal file
55
openhands-cli/openhands_cli/llm_utils.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Utility functions for LLM configuration in OpenHands CLI."""
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
|
||||
def get_llm_metadata(
|
||||
model_name: str,
|
||||
llm_type: str,
|
||||
session_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Generate LLM metadata for OpenHands CLI.
|
||||
|
||||
Args:
|
||||
model_name: Name of the LLM model
|
||||
agent_name: Name of the agent (defaults to "openhands-cli")
|
||||
session_id: Optional session identifier
|
||||
user_id: Optional user identifier
|
||||
|
||||
Returns:
|
||||
Dictionary containing metadata for LLM initialization
|
||||
"""
|
||||
# Import here to avoid circular imports
|
||||
openhands_sdk_version: str = "n/a"
|
||||
try:
|
||||
import openhands.sdk
|
||||
openhands_sdk_version = openhands.sdk.__version__
|
||||
except (ModuleNotFoundError, AttributeError):
|
||||
pass
|
||||
|
||||
openhands_tools_version: str = "n/a"
|
||||
try:
|
||||
import openhands.tools
|
||||
openhands_tools_version = openhands.tools.__version__
|
||||
except (ModuleNotFoundError, AttributeError):
|
||||
pass
|
||||
|
||||
metadata = {
|
||||
"trace_version": openhands_sdk_version,
|
||||
"tags": [
|
||||
"app:openhands-cli",
|
||||
f"model:{model_name}",
|
||||
f"type:{llm_type}",
|
||||
f"web_host:{os.environ.get('WEB_HOST', 'unspecified')}",
|
||||
f"openhands_sdk_version:{openhands_sdk_version}",
|
||||
f"openhands_tools_version:{openhands_tools_version}",
|
||||
],
|
||||
}
|
||||
if session_id is not None:
|
||||
metadata["session_id"] = session_id
|
||||
if user_id is not None:
|
||||
metadata["trace_user_id"] = user_id
|
||||
return metadata
|
||||
@@ -9,6 +9,7 @@ from prompt_toolkit.shortcuts import clear
|
||||
|
||||
from openhands_cli import __version__
|
||||
from openhands_cli.pt_style import get_cli_style
|
||||
from openhands_cli.conversation_manager import ConversationManager
|
||||
|
||||
DEFAULT_STYLE = get_cli_style()
|
||||
|
||||
@@ -23,17 +24,35 @@ COMMANDS = {
|
||||
'/resume': 'Resume a paused conversation',
|
||||
'/settings': 'Display and modify current settings',
|
||||
'/mcp': 'View MCP (Model Context Protocol) server configuration',
|
||||
'/list': 'List past conversations',
|
||||
'/load': 'Load a past conversation by ID',
|
||||
}
|
||||
|
||||
|
||||
class CommandCompleter(Completer):
|
||||
"""Custom completer for commands with interactive dropdown."""
|
||||
|
||||
def __init__(self):
|
||||
self.conversation_manager = ConversationManager()
|
||||
|
||||
def get_completions(
|
||||
self, document: Document, complete_event: CompleteEvent
|
||||
) -> Generator[Completion, None, None]:
|
||||
text = document.text_before_cursor.lstrip()
|
||||
if text.startswith('/'):
|
||||
|
||||
if text.startswith('/load '):
|
||||
# Handle conversation ID completion for /load command
|
||||
partial_id = text[6:] # Remove "/load "
|
||||
suggestions = self.conversation_manager.get_conversation_suggestions(partial_id)
|
||||
for suggestion in suggestions:
|
||||
yield Completion(
|
||||
suggestion,
|
||||
start_position=-len(partial_id),
|
||||
display_meta='conversation ID',
|
||||
style='bg:ansidarkgray fg:lightblue',
|
||||
)
|
||||
elif text.startswith('/'):
|
||||
# Handle command completion
|
||||
for command, description in COMMANDS.items():
|
||||
if command.startswith(text):
|
||||
yield Completion(
|
||||
|
||||
438
openhands-cli/tests/test_conversation_manager.py
Normal file
438
openhands-cli/tests/test_conversation_manager.py
Normal file
@@ -0,0 +1,438 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for conversation manager functionality.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import uuid
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from openhands_cli.conversation_manager import ConversationInfo, ConversationManager
|
||||
|
||||
|
||||
class TestConversationInfo:
|
||||
"""Test ConversationInfo class."""
|
||||
|
||||
def test_short_id(self):
|
||||
"""Test short ID generation."""
|
||||
conversation_id = "12345678-1234-1234-1234-123456789012"
|
||||
created_at = datetime.now(timezone.utc)
|
||||
info = ConversationInfo(conversation_id, created_at)
|
||||
assert info.short_id == "12345678"
|
||||
|
||||
def test_format_date_minutes(self):
|
||||
"""Test date formatting for recent times."""
|
||||
now = datetime.now(timezone.utc)
|
||||
# Use timedelta to avoid minute underflow
|
||||
created_at = now - timedelta(minutes=5)
|
||||
info = ConversationInfo("test-id", created_at)
|
||||
formatted = info.format_date(created_at)
|
||||
assert "m ago" in formatted
|
||||
|
||||
def test_format_date_hours(self):
|
||||
"""Test date formatting for hours ago."""
|
||||
now = datetime.now(timezone.utc)
|
||||
created_at = datetime(now.year, now.month, now.day, now.hour - 2, tzinfo=timezone.utc)
|
||||
info = ConversationInfo("test-id", created_at)
|
||||
formatted = info.format_date(created_at)
|
||||
assert "h ago" in formatted
|
||||
|
||||
def test_format_date_days(self):
|
||||
"""Test date formatting for days ago."""
|
||||
now = datetime.now(timezone.utc)
|
||||
# Create a date 3 days ago
|
||||
import datetime as dt
|
||||
created_at = now - dt.timedelta(days=3)
|
||||
info = ConversationInfo("test-id", created_at)
|
||||
formatted = info.format_date(created_at)
|
||||
assert "days ago" in formatted
|
||||
|
||||
|
||||
class TestConversationManager:
|
||||
"""Test ConversationManager class."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test environment."""
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.conversations_dir = os.path.join(self.temp_dir, "conversation")
|
||||
os.makedirs(self.conversations_dir, exist_ok=True)
|
||||
|
||||
def teardown_method(self):
|
||||
"""Clean up test environment."""
|
||||
import shutil
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def create_test_conversation(self, conversation_id: str, title: str = None,
|
||||
created_at: datetime = None) -> str:
|
||||
"""Create a test conversation directory with metadata."""
|
||||
if created_at is None:
|
||||
created_at = datetime.now(timezone.utc)
|
||||
|
||||
conv_dir = os.path.join(self.conversations_dir, conversation_id)
|
||||
os.makedirs(conv_dir, exist_ok=True)
|
||||
|
||||
# Create metadata file
|
||||
metadata = {
|
||||
"conversation_id": conversation_id,
|
||||
"title": title,
|
||||
"created_at": created_at.isoformat(),
|
||||
"last_updated_at": created_at.isoformat()
|
||||
}
|
||||
|
||||
metadata_file = os.path.join(conv_dir, "metadata.json")
|
||||
with open(metadata_file, 'w') as f:
|
||||
json.dump(metadata, f)
|
||||
|
||||
return conv_dir
|
||||
|
||||
def create_test_conversation_with_messages(self, conversation_id: str, first_message: str) -> str:
|
||||
"""Create a test conversation with a conversation.json file."""
|
||||
conv_dir = os.path.join(self.conversations_dir, conversation_id)
|
||||
os.makedirs(conv_dir, exist_ok=True)
|
||||
|
||||
# Create conversation file with events
|
||||
conversation_data = {
|
||||
"events": [
|
||||
{
|
||||
"source": "user",
|
||||
"message": first_message,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat()
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
conversation_file = os.path.join(conv_dir, "conversation.json")
|
||||
with open(conversation_file, 'w') as f:
|
||||
json.dump(conversation_data, f)
|
||||
|
||||
return conv_dir
|
||||
|
||||
@patch('openhands_cli.conversation_manager.PERSISTENCE_DIR')
|
||||
def test_discover_conversations_empty(self, mock_persistence_dir):
|
||||
"""Test discovering conversations when none exist."""
|
||||
mock_persistence_dir.return_value = self.temp_dir
|
||||
|
||||
manager = ConversationManager()
|
||||
manager.conversations_dir = self.conversations_dir
|
||||
|
||||
conversations = manager.discover_conversations()
|
||||
assert len(conversations) == 0
|
||||
|
||||
@patch('openhands_cli.conversation_manager.PERSISTENCE_DIR')
|
||||
def test_discover_conversations_with_metadata(self, mock_persistence_dir):
|
||||
"""Test discovering conversations with metadata."""
|
||||
mock_persistence_dir.return_value = self.temp_dir
|
||||
|
||||
# Create test conversations
|
||||
conv_id1 = str(uuid.uuid4())
|
||||
conv_id2 = str(uuid.uuid4())
|
||||
|
||||
self.create_test_conversation(conv_id1, "Test Conversation 1")
|
||||
self.create_test_conversation(conv_id2, "Test Conversation 2")
|
||||
|
||||
manager = ConversationManager()
|
||||
manager.conversations_dir = self.conversations_dir
|
||||
|
||||
conversations = manager.discover_conversations()
|
||||
assert len(conversations) == 2
|
||||
|
||||
# Check that conversations are sorted by last_updated_at (most recent first)
|
||||
assert all(isinstance(conv, ConversationInfo) for conv in conversations)
|
||||
assert any(conv.title == "Test Conversation 1" for conv in conversations)
|
||||
assert any(conv.title == "Test Conversation 2" for conv in conversations)
|
||||
|
||||
@patch('openhands_cli.conversation_manager.PERSISTENCE_DIR')
|
||||
def test_discover_conversations_with_messages(self, mock_persistence_dir):
|
||||
"""Test discovering conversations and extracting title from messages."""
|
||||
mock_persistence_dir.return_value = self.temp_dir
|
||||
|
||||
conv_id = str(uuid.uuid4())
|
||||
first_message = "Hello, can you help me with Python programming?"
|
||||
|
||||
self.create_test_conversation_with_messages(conv_id, first_message)
|
||||
|
||||
manager = ConversationManager()
|
||||
manager.conversations_dir = self.conversations_dir
|
||||
|
||||
conversations = manager.discover_conversations()
|
||||
assert len(conversations) == 1
|
||||
assert conversations[0].title == first_message
|
||||
|
||||
@patch('openhands_cli.conversation_manager.PERSISTENCE_DIR')
|
||||
def test_discover_conversations_long_message_truncation(self, mock_persistence_dir):
|
||||
"""Test that long messages are truncated for titles."""
|
||||
mock_persistence_dir.return_value = self.temp_dir
|
||||
|
||||
conv_id = str(uuid.uuid4())
|
||||
long_message = "This is a very long message that should be truncated because it exceeds the maximum length for display purposes"
|
||||
|
||||
self.create_test_conversation_with_messages(conv_id, long_message)
|
||||
|
||||
manager = ConversationManager()
|
||||
manager.conversations_dir = self.conversations_dir
|
||||
|
||||
conversations = manager.discover_conversations()
|
||||
assert len(conversations) == 1
|
||||
assert len(conversations[0].title) <= 50
|
||||
assert conversations[0].title.endswith("...")
|
||||
|
||||
@patch('openhands_cli.conversation_manager.PERSISTENCE_DIR')
|
||||
def test_get_conversation_suggestions(self, mock_persistence_dir):
|
||||
"""Test conversation ID suggestions for completion."""
|
||||
mock_persistence_dir.return_value = self.temp_dir
|
||||
|
||||
# Create test conversations with specific IDs
|
||||
conv_id1 = "12345678-1234-1234-1234-123456789012"
|
||||
conv_id2 = "12abcdef-1234-1234-1234-123456789012"
|
||||
conv_id3 = "87654321-1234-1234-1234-123456789012"
|
||||
|
||||
self.create_test_conversation(conv_id1, "Test 1")
|
||||
self.create_test_conversation(conv_id2, "Test 2")
|
||||
self.create_test_conversation(conv_id3, "Test 3")
|
||||
|
||||
manager = ConversationManager()
|
||||
manager.conversations_dir = self.conversations_dir
|
||||
|
||||
# Test partial matching - "123" should match conv_id1 (both full and short ID)
|
||||
suggestions = manager.get_conversation_suggestions("123")
|
||||
assert len(suggestions) == 2 # Should match conv_id1 full and short ID
|
||||
assert conv_id1 in suggestions
|
||||
assert "12345678" in suggestions
|
||||
|
||||
# Test another partial match - "12" should match conv_id1 and conv_id2
|
||||
suggestions = manager.get_conversation_suggestions("12")
|
||||
assert len(suggestions) >= 2 # Should match at least conv_id1 and conv_id2
|
||||
assert conv_id1 in suggestions
|
||||
assert conv_id2 in suggestions
|
||||
|
||||
# Test short ID exact matching
|
||||
suggestions = manager.get_conversation_suggestions("12345678")
|
||||
assert "12345678" in suggestions or conv_id1 in suggestions
|
||||
|
||||
@patch('openhands_cli.conversation_manager.PERSISTENCE_DIR')
|
||||
def test_invalid_conversation_directories_ignored(self, mock_persistence_dir):
|
||||
"""Test that invalid conversation directories are ignored."""
|
||||
mock_persistence_dir.return_value = self.temp_dir
|
||||
|
||||
# Create valid conversation
|
||||
valid_conv_id = str(uuid.uuid4())
|
||||
self.create_test_conversation(valid_conv_id, "Valid Conversation")
|
||||
|
||||
# Create invalid directories (not UUIDs)
|
||||
invalid_dir1 = os.path.join(self.conversations_dir, "not-a-uuid")
|
||||
invalid_dir2 = os.path.join(self.conversations_dir, "also-not-uuid")
|
||||
os.makedirs(invalid_dir1)
|
||||
os.makedirs(invalid_dir2)
|
||||
|
||||
# Create a file (not directory)
|
||||
invalid_file = os.path.join(self.conversations_dir, "some-file.txt")
|
||||
with open(invalid_file, 'w') as f:
|
||||
f.write("test")
|
||||
|
||||
manager = ConversationManager()
|
||||
manager.conversations_dir = self.conversations_dir
|
||||
|
||||
conversations = manager.discover_conversations()
|
||||
assert len(conversations) == 1 # Only the valid conversation
|
||||
assert conversations[0].conversation_id == valid_conv_id
|
||||
|
||||
@patch('openhands_cli.conversation_manager.PERSISTENCE_DIR')
|
||||
def test_extract_title_from_conversation_no_user_messages(self, mock_persistence_dir):
|
||||
"""Test title extraction when there are no user messages."""
|
||||
mock_persistence_dir.return_value = self.temp_dir
|
||||
|
||||
conv_id = str(uuid.uuid4())
|
||||
conv_dir = os.path.join(self.conversations_dir, conv_id)
|
||||
os.makedirs(conv_dir, exist_ok=True)
|
||||
|
||||
# Create conversation file with no user messages
|
||||
conversation_data = {
|
||||
"events": [
|
||||
{
|
||||
"source": "assistant",
|
||||
"message": "Hello! How can I help you?",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat()
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
conversation_file = os.path.join(conv_dir, "conversation.json")
|
||||
with open(conversation_file, 'w') as f:
|
||||
json.dump(conversation_data, f)
|
||||
|
||||
manager = ConversationManager()
|
||||
manager.conversations_dir = self.conversations_dir
|
||||
|
||||
conversations = manager.discover_conversations()
|
||||
assert len(conversations) == 1
|
||||
assert conversations[0].title is None # No user message found
|
||||
|
||||
# Tests for conversation viewing functionality
|
||||
|
||||
@patch('openhands_cli.conversation_manager.PERSISTENCE_DIR')
|
||||
def test_load_conversation_events_nonexistent(self, mock_persistence_dir):
|
||||
"""Test loading events from non-existent conversation."""
|
||||
mock_persistence_dir.return_value = self.temp_dir
|
||||
|
||||
manager = ConversationManager()
|
||||
manager.conversations_dir = self.conversations_dir
|
||||
|
||||
events = manager._load_conversation_events("nonexistent-id")
|
||||
assert events == []
|
||||
|
||||
@patch('openhands_cli.conversation_manager.PERSISTENCE_DIR')
|
||||
def test_load_conversation_events_no_events_file(self, mock_persistence_dir):
|
||||
"""Test loading events when events.jsonl doesn't exist."""
|
||||
mock_persistence_dir.return_value = self.temp_dir
|
||||
|
||||
# Create conversation directory without events file
|
||||
conv_id = "test-conv-id"
|
||||
conv_dir = os.path.join(self.conversations_dir, conv_id)
|
||||
os.makedirs(conv_dir, exist_ok=True)
|
||||
|
||||
manager = ConversationManager()
|
||||
manager.conversations_dir = self.conversations_dir
|
||||
|
||||
events = manager._load_conversation_events(conv_id)
|
||||
assert events == []
|
||||
|
||||
@patch('openhands_cli.conversation_manager.PERSISTENCE_DIR')
|
||||
def test_load_conversation_events_with_data(self, mock_persistence_dir):
|
||||
"""Test loading events from valid events.jsonl file."""
|
||||
mock_persistence_dir.return_value = self.temp_dir
|
||||
|
||||
# Create conversation directory with events file
|
||||
conv_id = "test-conv-id"
|
||||
conv_dir = os.path.join(self.conversations_dir, conv_id)
|
||||
os.makedirs(conv_dir, exist_ok=True)
|
||||
|
||||
events_file = os.path.join(conv_dir, "events.jsonl")
|
||||
sample_events = [
|
||||
{"event_type": "MessageAction", "source": "user", "content": "Hello", "timestamp": 1234567890},
|
||||
{"event_type": "CmdRunAction", "source": "agent", "command": "ls", "timestamp": 1234567891},
|
||||
{"event_type": "CmdOutputObservation", "source": "environment", "output": "file1.txt", "timestamp": 1234567892}
|
||||
]
|
||||
|
||||
with open(events_file, 'w') as f:
|
||||
for event in sample_events:
|
||||
f.write(json.dumps(event) + '\n')
|
||||
|
||||
manager = ConversationManager()
|
||||
manager.conversations_dir = self.conversations_dir
|
||||
|
||||
events = manager._load_conversation_events(conv_id)
|
||||
|
||||
assert len(events) == 3
|
||||
assert events[0]["event_type"] == "MessageAction"
|
||||
assert events[1]["event_type"] == "CmdRunAction"
|
||||
assert events[2]["event_type"] == "CmdOutputObservation"
|
||||
|
||||
def test_filter_events_by_source(self):
|
||||
"""Test filtering events by source."""
|
||||
manager = ConversationManager()
|
||||
|
||||
events = [
|
||||
{"event_type": "MessageAction", "source": "user", "content": "Hello"},
|
||||
{"event_type": "CmdRunAction", "source": "agent", "command": "ls"},
|
||||
{"event_type": "CmdOutputObservation", "source": "environment", "output": "file1.txt"}
|
||||
]
|
||||
|
||||
# Filter by user
|
||||
user_events = manager._filter_events(events, "user")
|
||||
assert len(user_events) == 1
|
||||
assert user_events[0]["source"] == "user"
|
||||
|
||||
# Filter by agent
|
||||
agent_events = manager._filter_events(events, "agent")
|
||||
assert len(agent_events) == 1
|
||||
assert agent_events[0]["source"] == "agent"
|
||||
|
||||
def test_filter_events_by_category(self):
|
||||
"""Test filtering events by category."""
|
||||
manager = ConversationManager()
|
||||
|
||||
events = [
|
||||
{"event_type": "MessageAction", "source": "user", "content": "Hello"},
|
||||
{"event_type": "CmdRunAction", "source": "agent", "command": "ls"},
|
||||
{"event_type": "CmdOutputObservation", "source": "environment", "output": "file1.txt"},
|
||||
{"event_type": "FileEditAction", "source": "agent", "path": "test.py"},
|
||||
{"event_type": "AgentThinkAction", "source": "agent", "thought": "I need to..."}
|
||||
]
|
||||
|
||||
# Filter by action category
|
||||
action_events = manager._filter_events(events, "action")
|
||||
assert len(action_events) == 4 # MessageAction, CmdRunAction, FileEditAction, AgentThinkAction
|
||||
|
||||
# Filter by observation category
|
||||
obs_events = manager._filter_events(events, "observation")
|
||||
assert len(obs_events) == 1 # CmdOutputObservation
|
||||
|
||||
# Filter by command category
|
||||
cmd_events = manager._filter_events(events, "command")
|
||||
assert len(cmd_events) == 1 # CmdRunAction
|
||||
|
||||
# Filter by file category
|
||||
file_events = manager._filter_events(events, "file")
|
||||
assert len(file_events) == 1 # FileEditAction
|
||||
|
||||
# Filter by think category
|
||||
think_events = manager._filter_events(events, "think")
|
||||
assert len(think_events) == 1 # AgentThinkAction
|
||||
|
||||
def test_matches_event_category(self):
|
||||
"""Test event category matching logic."""
|
||||
manager = ConversationManager()
|
||||
|
||||
# Test action matching
|
||||
action_event = {"event_type": "CmdRunAction", "source": "agent"}
|
||||
assert manager._matches_event_category(action_event, "action")
|
||||
assert manager._matches_event_category(action_event, "command")
|
||||
assert not manager._matches_event_category(action_event, "observation")
|
||||
|
||||
# Test observation matching
|
||||
obs_event = {"event_type": "CmdOutputObservation", "source": "environment"}
|
||||
assert manager._matches_event_category(obs_event, "observation")
|
||||
assert not manager._matches_event_category(obs_event, "action")
|
||||
|
||||
# Test file operations
|
||||
file_event = {"event_type": "FileEditAction", "source": "agent"}
|
||||
assert manager._matches_event_category(file_event, "file")
|
||||
assert manager._matches_event_category(file_event, "action")
|
||||
|
||||
def test_extract_event_content(self):
|
||||
"""Test extracting content from different event types."""
|
||||
manager = ConversationManager()
|
||||
|
||||
# Test direct content field
|
||||
event1 = {"event_type": "MessageAction", "content": "Hello world"}
|
||||
assert manager._extract_event_content(event1) == "Hello world"
|
||||
|
||||
# Test command field
|
||||
event2 = {"event_type": "CmdRunAction", "command": "ls -la"}
|
||||
assert manager._extract_event_content(event2) == "ls -la"
|
||||
|
||||
# Test args structure
|
||||
event3 = {"event_type": "FileEditAction", "args": {"path": "/test/file.py", "content": "print('hello')"}}
|
||||
content = manager._extract_event_content(event3)
|
||||
assert content in ["/test/file.py", "print('hello')"] # Either could be returned first
|
||||
|
||||
# Test empty event
|
||||
event4 = {"event_type": "UnknownAction"}
|
||||
assert manager._extract_event_content(event4) == ""
|
||||
|
||||
def test_get_available_filters(self):
|
||||
"""Test getting list of available filters."""
|
||||
manager = ConversationManager()
|
||||
|
||||
filters = manager.get_available_filters()
|
||||
expected_filters = ['action', 'observation', 'user', 'agent', 'command', 'file', 'browse', 'message', 'think']
|
||||
|
||||
assert len(filters) == len(expected_filters)
|
||||
for filter_name in expected_filters:
|
||||
assert filter_name in filters
|
||||
69
openhands-cli/tests/test_loading.py
Normal file
69
openhands-cli/tests/test_loading.py
Normal file
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Unit tests for the loading animation functionality.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from openhands_cli.listeners.loading_listener import (
|
||||
LoadingContext,
|
||||
display_initialization_animation
|
||||
)
|
||||
|
||||
class TestLoadingAnimation(unittest.TestCase):
|
||||
"""Test cases for loading animation functionality."""
|
||||
|
||||
def test_loading_context_manager(self):
|
||||
"""Test that LoadingContext works as a context manager."""
|
||||
with LoadingContext("Test loading...") as ctx:
|
||||
self.assertIsInstance(ctx, LoadingContext)
|
||||
self.assertEqual(ctx.text, "Test loading...")
|
||||
self.assertIsInstance(ctx.is_loaded, threading.Event)
|
||||
self.assertIsNotNone(ctx.loading_thread)
|
||||
# Give the thread a moment to start
|
||||
time.sleep(0.1)
|
||||
self.assertTrue(ctx.loading_thread.is_alive())
|
||||
|
||||
# After exiting context, thread should be stopped
|
||||
time.sleep(0.1)
|
||||
self.assertFalse(ctx.loading_thread.is_alive())
|
||||
|
||||
@patch('sys.stdout')
|
||||
def test_animation_writes_while_running_and_stops_after(self, mock_stdout):
|
||||
"""Ensure stdout is written while animation runs and stops after it ends."""
|
||||
is_loaded = threading.Event()
|
||||
|
||||
animation_thread = threading.Thread(
|
||||
target=display_initialization_animation,
|
||||
args=("Test output", is_loaded),
|
||||
daemon=True
|
||||
)
|
||||
animation_thread.start()
|
||||
|
||||
# Let it run a bit and check calls
|
||||
time.sleep(0.2)
|
||||
calls_while_running = mock_stdout.write.call_count
|
||||
self.assertGreater(calls_while_running, 0, "Expected writes while spinner runs")
|
||||
|
||||
# Stop animation
|
||||
is_loaded.set()
|
||||
time.sleep(0.2)
|
||||
|
||||
animation_thread.join(timeout=1.0)
|
||||
calls_after_stop = mock_stdout.write.call_count
|
||||
|
||||
# Wait a moment to detect any stray writes after thread finished
|
||||
time.sleep(0.2)
|
||||
self.assertEqual(
|
||||
calls_after_stop,
|
||||
mock_stdout.write.call_count,
|
||||
"No extra writes should occur after animation stops"
|
||||
)
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
126
openhands-cli/tests/test_settings_input.py
Normal file
126
openhands-cli/tests/test_settings_input.py
Normal file
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Core Settings Logic tests
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from prompt_toolkit.completion import FuzzyWordCompleter
|
||||
from prompt_toolkit.validation import ValidationError
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands_cli.user_actions.settings_action import (
|
||||
NonEmptyValueValidator,
|
||||
SettingsType,
|
||||
choose_llm_model,
|
||||
choose_llm_provider,
|
||||
prompt_api_key,
|
||||
settings_type_confirmation,
|
||||
)
|
||||
|
||||
|
||||
# -------------------------------
|
||||
# Settings type selection
|
||||
# -------------------------------
|
||||
|
||||
def test_settings_type_selection(mock_cli_interactions: Any) -> None:
|
||||
mocks = mock_cli_interactions
|
||||
|
||||
# Basic
|
||||
mocks.cli_confirm.return_value = 0
|
||||
assert settings_type_confirmation() == SettingsType.BASIC
|
||||
|
||||
# Cancel/Go back
|
||||
mocks.cli_confirm.return_value = 2
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
settings_type_confirmation()
|
||||
|
||||
|
||||
# -------------------------------
|
||||
# Provider selection flows
|
||||
# -------------------------------
|
||||
|
||||
def test_provider_selection_with_predefined_options(mock_verified_models: Any, mock_cli_interactions: Any) -> None:
|
||||
from openhands_cli.tui.utils import StepCounter
|
||||
mocks = mock_cli_interactions
|
||||
# first option among display_options is index 0
|
||||
mocks.cli_confirm.return_value = 0
|
||||
step_counter = StepCounter(1)
|
||||
result = choose_llm_provider(step_counter)
|
||||
assert result == 'openai'
|
||||
|
||||
|
||||
def test_provider_selection_with_custom_input(mock_verified_models: Any, mock_cli_interactions: Any) -> None:
|
||||
from openhands_cli.tui.utils import StepCounter
|
||||
mocks = mock_cli_interactions
|
||||
# Due to overlapping provider keys between VERIFIED and UNVERIFIED in fixture,
|
||||
# display_options contains 4 providers (with duplicates) + alternate at index 4
|
||||
mocks.cli_confirm.return_value = 4
|
||||
mocks.cli_text_input.return_value = "my-provider"
|
||||
step_counter = StepCounter(1)
|
||||
result = choose_llm_provider(step_counter)
|
||||
assert result == "my-provider"
|
||||
|
||||
# Verify fuzzy completer passed
|
||||
_, kwargs = mocks.cli_text_input.call_args
|
||||
assert isinstance(kwargs["completer"], FuzzyWordCompleter)
|
||||
|
||||
|
||||
# -------------------------------
|
||||
# Model selection flows
|
||||
# -------------------------------
|
||||
|
||||
def test_model_selection_flows(mock_verified_models: Any, mock_cli_interactions: Any) -> None:
|
||||
from openhands_cli.tui.utils import StepCounter
|
||||
mocks = mock_cli_interactions
|
||||
|
||||
# Direct pick from predefined list
|
||||
mocks.cli_confirm.return_value = 0
|
||||
step_counter = StepCounter(1)
|
||||
result = choose_llm_model(step_counter, "openai")
|
||||
assert result in ["gpt-4o"]
|
||||
|
||||
# Choose custom model via input
|
||||
mocks.cli_confirm.return_value = 4 # for provider with >=4 models this would be alt; in our data openai has 3 -> alt index is 3
|
||||
mocks.cli_text_input.return_value = "custom-model"
|
||||
# Adjust to actual alt index produced by code (len(models[:4]) yields 3 + 1 alt -> index 3)
|
||||
mocks.cli_confirm.return_value = 3
|
||||
step_counter2 = StepCounter(1)
|
||||
result2 = choose_llm_model(step_counter2, "openai")
|
||||
assert result2 == "custom-model"
|
||||
|
||||
|
||||
# -------------------------------
|
||||
# API key validation and prompting
|
||||
# -------------------------------
|
||||
|
||||
def test_api_key_validation_and_prompting(mock_cli_interactions: Any) -> None:
|
||||
# Validator standalone
|
||||
validator = NonEmptyValueValidator()
|
||||
doc = MagicMock(); doc.text = "sk-abc"
|
||||
validator.validate(doc)
|
||||
|
||||
doc_empty = MagicMock(); doc_empty.text = ""
|
||||
with pytest.raises(ValidationError):
|
||||
validator.validate(doc_empty)
|
||||
|
||||
# Prompting for new key enforces validator
|
||||
from openhands_cli.tui.utils import StepCounter
|
||||
mocks = mock_cli_interactions
|
||||
mocks.cli_text_input.return_value = "sk-new"
|
||||
step_counter = StepCounter(1)
|
||||
new_key = prompt_api_key(step_counter, 'provider')
|
||||
assert new_key == "sk-new"
|
||||
assert mocks.cli_text_input.call_args[1]["validator"] is not None
|
||||
|
||||
# Prompting with existing key shows mask and no validator
|
||||
mocks.cli_text_input.reset_mock()
|
||||
mocks.cli_text_input.return_value = "sk-updated"
|
||||
existing = SecretStr("sk-existing-123")
|
||||
step_counter2 = StepCounter(1)
|
||||
updated = prompt_api_key(step_counter2, 'provider', existing)
|
||||
assert updated == "sk-updated"
|
||||
assert mocks.cli_text_input.call_args[1]["validator"] is None
|
||||
assert "sk-***" in mocks.cli_text_input.call_args[0][0]
|
||||
132
openhands-cli/tests/test_settings_workflow.py
Normal file
132
openhands-cli/tests/test_settings_workflow.py
Normal file
@@ -0,0 +1,132 @@
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
from openhands_cli.tui.settings.settings_screen import SettingsScreen
|
||||
from pathlib import Path
|
||||
|
||||
from openhands.sdk import LLM, Conversation, LocalFileStore
|
||||
from openhands.tools.preset.default import get_default_agent
|
||||
from openhands_cli.tui.settings.store import AgentStore
|
||||
from openhands_cli.user_actions.settings_action import SettingsType
|
||||
from pydantic import SecretStr
|
||||
import pytest
|
||||
|
||||
def read_json(path: Path) -> dict:
|
||||
with open(path, "r") as f:
|
||||
return json.load(f)
|
||||
|
||||
def make_screen_with_conversation(model="openai/gpt-4o-mini", api_key="sk-xyz"):
|
||||
llm = LLM(model=model, api_key=SecretStr(api_key), service_id="test-service")
|
||||
# Conversation(agent) signature may vary across versions; adapt if needed:
|
||||
from openhands.sdk.agent import Agent
|
||||
agent = Agent(llm=llm, tools=[])
|
||||
conv = Conversation(agent)
|
||||
return SettingsScreen(conversation=conv)
|
||||
|
||||
def seed_file(path: Path, model: str = "openai/gpt-4o-mini", api_key: str = "sk-old"):
|
||||
store = AgentStore()
|
||||
store.file_store = LocalFileStore(root=str(path))
|
||||
agent = get_default_agent(
|
||||
llm=LLM(model=model, api_key=SecretStr(api_key), service_id="test-service")
|
||||
)
|
||||
store.save(agent)
|
||||
|
||||
|
||||
def test_llm_settings_save_and_load(tmp_path: Path):
|
||||
"""Test that the settings screen can save basic LLM settings."""
|
||||
screen = SettingsScreen(conversation=None)
|
||||
|
||||
# Mock the spec store to verify settings are saved
|
||||
with patch.object(screen.agent_store, 'save') as mock_save:
|
||||
screen._save_llm_settings(
|
||||
model="openai/gpt-4o-mini",
|
||||
api_key="sk-test-123"
|
||||
)
|
||||
|
||||
# Verify that save was called
|
||||
mock_save.assert_called_once()
|
||||
|
||||
# Get the agent spec that was saved
|
||||
saved_spec = mock_save.call_args[0][0]
|
||||
assert saved_spec.llm.model == "openai/gpt-4o-mini"
|
||||
assert saved_spec.llm.api_key.get_secret_value() == "sk-test-123"
|
||||
|
||||
|
||||
def test_first_time_setup_workflow(tmp_path: Path):
|
||||
"""Test that the basic settings workflow completes without errors."""
|
||||
screen = SettingsScreen()
|
||||
|
||||
with (
|
||||
patch("openhands_cli.tui.settings.settings_screen.settings_type_confirmation", return_value=SettingsType.BASIC),
|
||||
patch("openhands_cli.tui.settings.settings_screen.choose_llm_provider", return_value="openai"),
|
||||
patch("openhands_cli.tui.settings.settings_screen.choose_llm_model", return_value="gpt-4o-mini"),
|
||||
patch("openhands_cli.tui.settings.settings_screen.prompt_api_key", return_value="sk-first"),
|
||||
patch("openhands_cli.tui.settings.settings_screen.save_settings_confirmation", return_value=True),
|
||||
):
|
||||
# The workflow should complete without errors
|
||||
screen.configure_settings()
|
||||
|
||||
# Since the current implementation doesn't save to file, we just verify the workflow completed
|
||||
assert True # If we get here, the workflow completed successfully
|
||||
|
||||
|
||||
def test_update_existing_settings_workflow(tmp_path: Path):
|
||||
"""Test that the settings update workflow completes without errors."""
|
||||
settings_path = tmp_path / "agent_settings.json"
|
||||
seed_file(settings_path, model="openai/gpt-4o-mini", api_key="sk-old")
|
||||
screen = make_screen_with_conversation(model="openai/gpt-4o-mini", api_key="sk-old")
|
||||
|
||||
with (
|
||||
patch("openhands_cli.tui.settings.settings_screen.settings_type_confirmation", return_value=SettingsType.BASIC),
|
||||
patch("openhands_cli.tui.settings.settings_screen.choose_llm_provider", return_value="anthropic"),
|
||||
patch("openhands_cli.tui.settings.settings_screen.choose_llm_model", return_value="claude-3-5-sonnet"),
|
||||
patch("openhands_cli.tui.settings.settings_screen.prompt_api_key", return_value="sk-updated"),
|
||||
patch("openhands_cli.tui.settings.settings_screen.save_settings_confirmation", return_value=True),
|
||||
):
|
||||
# The workflow should complete without errors
|
||||
screen.configure_settings()
|
||||
|
||||
# Since the current implementation doesn't save to file, we just verify the workflow completed
|
||||
assert True # If we get here, the workflow completed successfully
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"step_to_cancel",
|
||||
["type", "provider", "model", "apikey", "save"],
|
||||
)
|
||||
def test_workflow_cancellation_at_each_step(tmp_path: Path, step_to_cancel: str):
|
||||
screen = make_screen_with_conversation()
|
||||
|
||||
# Base happy-path patches
|
||||
patches = {
|
||||
"settings_type_confirmation": MagicMock(return_value=SettingsType.BASIC),
|
||||
"choose_llm_provider": MagicMock(return_value="openai"),
|
||||
"choose_llm_model": MagicMock(return_value="gpt-4o-mini"),
|
||||
"prompt_api_key": MagicMock(return_value="sk-new"),
|
||||
"save_settings_confirmation": MagicMock(return_value=True),
|
||||
}
|
||||
|
||||
# Turn one step into a cancel
|
||||
if step_to_cancel == "type":
|
||||
patches["settings_type_confirmation"].side_effect = KeyboardInterrupt()
|
||||
elif step_to_cancel == "provider":
|
||||
patches["choose_llm_provider"].side_effect = KeyboardInterrupt()
|
||||
elif step_to_cancel == "model":
|
||||
patches["choose_llm_model"].side_effect = KeyboardInterrupt()
|
||||
elif step_to_cancel == "apikey":
|
||||
patches["prompt_api_key"].side_effect = KeyboardInterrupt()
|
||||
elif step_to_cancel == "save":
|
||||
patches["save_settings_confirmation"].side_effect = KeyboardInterrupt()
|
||||
|
||||
with (
|
||||
patch("openhands_cli.tui.settings.settings_screen.settings_type_confirmation", patches["settings_type_confirmation"]),
|
||||
patch("openhands_cli.tui.settings.settings_screen.choose_llm_provider", patches["choose_llm_provider"]),
|
||||
patch("openhands_cli.tui.settings.settings_screen.choose_llm_model", patches["choose_llm_model"]),
|
||||
patch("openhands_cli.tui.settings.settings_screen.prompt_api_key", patches["prompt_api_key"]),
|
||||
patch("openhands_cli.tui.settings.settings_screen.save_settings_confirmation", patches["save_settings_confirmation"]),
|
||||
patch.object(screen.agent_store, 'save') as mock_save,
|
||||
):
|
||||
screen.configure_settings()
|
||||
|
||||
# No settings should be saved on cancel
|
||||
mock_save.assert_not_called()
|
||||
|
||||
35
poetry.lock
generated
35
poetry.lock
generated
@@ -3002,6 +3002,41 @@ files = [
|
||||
{file = "fastuuid-0.13.5.tar.gz", hash = "sha256:d4976821ab424d41542e1ea39bc828a9d454c3f8a04067c06fca123c5b95a1a1"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastuuid"
|
||||
version = "0.12.0"
|
||||
description = "Python bindings to Rust's UUID library."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "fastuuid-0.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:22a900ef0956aacf862b460e20541fdae2d7c340594fe1bd6fdcb10d5f0791a9"},
|
||||
{file = "fastuuid-0.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0302f5acf54dc75de30103025c5a95db06d6c2be36829043a0aa16fc170076bc"},
|
||||
{file = "fastuuid-0.12.0-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:7946b4a310cfc2d597dcba658019d72a2851612a2cebb949d809c0e2474cf0a6"},
|
||||
{file = "fastuuid-0.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:a1b6764dd42bf0c46c858fb5ade7b7a3d93b7a27485a7a5c184909026694cd88"},
|
||||
{file = "fastuuid-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2bced35269315d16fe0c41003f8c9d63f2ee16a59295d90922cad5e6a67d0418"},
|
||||
{file = "fastuuid-0.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82106e4b0a24f4f2f73c88f89dadbc1533bb808900740ca5db9bbb17d3b0c824"},
|
||||
{file = "fastuuid-0.12.0-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:4db1bc7b8caa1d7412e1bea29b016d23a8d219131cff825b933eb3428f044dca"},
|
||||
{file = "fastuuid-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:07afc8e674e67ac3d35a608c68f6809da5fab470fb4ef4469094fdb32ba36c51"},
|
||||
{file = "fastuuid-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:328694a573fe9dce556b0b70c9d03776786801e028d82f0b6d9db1cb0521b4d1"},
|
||||
{file = "fastuuid-0.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02acaea2c955bb2035a7d8e7b3fba8bd623b03746ae278e5fa932ef54c702f9f"},
|
||||
{file = "fastuuid-0.12.0-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:ed9f449cba8cf16cced252521aee06e633d50ec48c807683f21cc1d89e193eb0"},
|
||||
{file = "fastuuid-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:0df2ea4c9db96fd8f4fa38d0e88e309b3e56f8fd03675a2f6958a5b082a0c1e4"},
|
||||
{file = "fastuuid-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7fe2407316a04ee8f06d3dbc7eae396d0a86591d92bafe2ca32fce23b1145786"},
|
||||
{file = "fastuuid-0.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b9b31dd488d0778c36f8279b306dc92a42f16904cba54acca71e107d65b60b0c"},
|
||||
{file = "fastuuid-0.12.0-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:b19361ee649365eefc717ec08005972d3d1eb9ee39908022d98e3bfa9da59e37"},
|
||||
{file = "fastuuid-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:8fc66b11423e6f3e1937385f655bedd67aebe56a3dcec0cb835351cfe7d358c9"},
|
||||
{file = "fastuuid-0.12.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7b15c54d300279ab20a9cc0579ada9c9f80d1bc92997fc61fb7bf3103d7cb26b"},
|
||||
{file = "fastuuid-0.12.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:458f1bc3ebbd76fdb89ad83e6b81ccd3b2a99fa6707cd3650b27606745cfb170"},
|
||||
{file = "fastuuid-0.12.0-cp38-cp38-manylinux_2_34_x86_64.whl", hash = "sha256:a8f0f83fbba6dc44271a11b22e15838641b8c45612cdf541b4822a5930f6893c"},
|
||||
{file = "fastuuid-0.12.0-cp38-cp38-win_amd64.whl", hash = "sha256:7cfd2092253d3441f6a8c66feff3c3c009da25a5b3da82bc73737558543632be"},
|
||||
{file = "fastuuid-0.12.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9303617e887429c193d036d47d0b32b774ed3618431123e9106f610d601eb57e"},
|
||||
{file = "fastuuid-0.12.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8790221325b376e1122e95f865753ebf456a9fb8faf0dca4f9bf7a3ff620e413"},
|
||||
{file = "fastuuid-0.12.0-cp39-cp39-manylinux_2_34_x86_64.whl", hash = "sha256:e4b12d3e23515e29773fa61644daa660ceb7725e05397a986c2109f512579a48"},
|
||||
{file = "fastuuid-0.12.0-cp39-cp39-win_amd64.whl", hash = "sha256:e41656457c34b5dcb784729537ea64c7d9bbaf7047b480c6c6a64c53379f455a"},
|
||||
{file = "fastuuid-0.12.0.tar.gz", hash = "sha256:d0bd4e5b35aad2826403f4411937c89e7c88857b1513fe10f696544c03e9bd8e"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filelock"
|
||||
version = "3.18.0"
|
||||
|
||||
290
tests/unit/test_pricing_documentation.py
Normal file
290
tests/unit/test_pricing_documentation.py
Normal file
@@ -0,0 +1,290 @@
|
||||
"""
|
||||
Unit tests to verify pricing documentation consistency.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
|
||||
class TestPricingDocumentation:
|
||||
"""Test class for pricing documentation consistency."""
|
||||
|
||||
@pytest.fixture
|
||||
def pricing_data(self) -> dict[str, Any]:
|
||||
"""Fetch pricing data from LiteLLM repository."""
|
||||
url = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json'
|
||||
response = requests.get(url)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
@pytest.fixture
|
||||
def openhands_models(self) -> list[str]:
|
||||
"""Get the list of OpenHands models from the codebase."""
|
||||
# Read the models directly from the source code file
|
||||
llm_utils_path = (
|
||||
Path(__file__).parent.parent.parent / 'openhands' / 'utils' / 'llm.py'
|
||||
)
|
||||
content = llm_utils_path.read_text()
|
||||
|
||||
# Extract the openhands_models list from the file
|
||||
import ast
|
||||
|
||||
# Parse the Python file
|
||||
tree = ast.parse(content)
|
||||
|
||||
# Find the openhands_models assignment
|
||||
for node in ast.walk(tree):
|
||||
if (
|
||||
isinstance(node, ast.Assign)
|
||||
and len(node.targets) == 1
|
||||
and isinstance(node.targets[0], ast.Name)
|
||||
and node.targets[0].id == 'openhands_models'
|
||||
):
|
||||
# Extract the list values
|
||||
if isinstance(node.value, ast.List):
|
||||
models = []
|
||||
for elt in node.value.elts:
|
||||
if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
|
||||
# Remove 'openhands/' prefix and filter out secret models
|
||||
model = elt.value
|
||||
if model.startswith('openhands/'):
|
||||
model = model[10:] # Remove 'openhands/' prefix
|
||||
if not model.startswith('<secret'):
|
||||
models.append(model)
|
||||
return models
|
||||
|
||||
# Fallback if parsing fails
|
||||
raise ValueError('Could not extract openhands_models from llm.py')
|
||||
|
||||
@pytest.fixture
|
||||
def documentation_content(self) -> str:
|
||||
"""Read the OpenHands LLM documentation content."""
|
||||
docs_path = (
|
||||
Path(__file__).parent.parent.parent
|
||||
/ 'docs'
|
||||
/ 'usage'
|
||||
/ 'llms'
|
||||
/ 'openhands-llms.mdx'
|
||||
)
|
||||
return docs_path.read_text()
|
||||
|
||||
def extract_pricing_from_docs(self, content: str) -> dict[str, dict[str, float]]:
|
||||
"""Extract pricing information from documentation."""
|
||||
# Updated pattern to handle cached input cost column (which can be N/A)
|
||||
pricing_table_pattern = (
|
||||
r'\| ([^|]+) \| \$([0-9.]+) \| ([^|]+) \| \$([0-9.]+) \|'
|
||||
)
|
||||
matches = re.findall(pricing_table_pattern, content)
|
||||
|
||||
pricing_data = {}
|
||||
for match in matches:
|
||||
model_name = match[0].strip()
|
||||
input_cost = float(match[1])
|
||||
cached_input_str = match[2].strip()
|
||||
output_cost = float(match[3])
|
||||
|
||||
# Parse cached input cost (can be N/A or $X.XX)
|
||||
cached_input_cost = None
|
||||
if cached_input_str != 'N/A':
|
||||
cached_input_cost = float(cached_input_str.replace('$', ''))
|
||||
|
||||
pricing_data[model_name] = {
|
||||
'input_cost_per_million_tokens': input_cost,
|
||||
'cached_input_cost_per_million_tokens': cached_input_cost,
|
||||
'output_cost_per_million_tokens': output_cost,
|
||||
}
|
||||
|
||||
return pricing_data
|
||||
|
||||
def get_litellm_pricing(
|
||||
self, model: str, pricing_data: dict[str, Any]
|
||||
) -> dict[str, float]:
|
||||
"""Get pricing for a model from LiteLLM data."""
|
||||
# Try different variations of the model name
|
||||
variations = [
|
||||
model,
|
||||
f'openai/{model}',
|
||||
f'anthropic/{model}',
|
||||
f'google/{model}',
|
||||
f'mistral/{model}',
|
||||
]
|
||||
|
||||
for variation in variations:
|
||||
if variation in pricing_data:
|
||||
model_data = pricing_data[variation]
|
||||
result = {
|
||||
'input_cost_per_million_tokens': model_data.get(
|
||||
'input_cost_per_token', 0
|
||||
)
|
||||
* 1_000_000,
|
||||
'output_cost_per_million_tokens': model_data.get(
|
||||
'output_cost_per_token', 0
|
||||
)
|
||||
* 1_000_000,
|
||||
}
|
||||
|
||||
# Add cached input cost if available
|
||||
cached_cost = model_data.get('cache_read_input_token_cost', 0)
|
||||
if cached_cost > 0:
|
||||
result['cached_input_cost_per_million_tokens'] = (
|
||||
cached_cost * 1_000_000
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
return {}
|
||||
|
||||
def test_pricing_table_exists(self, documentation_content: str):
|
||||
"""Test that the pricing table exists in the documentation."""
|
||||
assert (
|
||||
'| Model | Input Cost (per 1M tokens) | Cached Input Cost (per 1M tokens) | Output Cost (per 1M tokens)'
|
||||
in documentation_content
|
||||
)
|
||||
assert 'claude-opus-4-20250514' in documentation_content
|
||||
assert 'qwen3-coder-480b' in documentation_content
|
||||
|
||||
def test_no_external_json_link(self, documentation_content: str):
|
||||
"""Test that the external JSON link has been removed."""
|
||||
assert (
|
||||
'github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json'
|
||||
not in documentation_content
|
||||
)
|
||||
|
||||
def test_pricing_consistency_with_litellm(
|
||||
self, pricing_data: dict[str, Any], documentation_content: str
|
||||
):
|
||||
"""Test that pricing in documentation matches LiteLLM data where applicable."""
|
||||
docs_pricing = self.extract_pricing_from_docs(documentation_content)
|
||||
|
||||
# Special case for qwen3-coder-480b (custom pricing)
|
||||
qwen_pricing = docs_pricing.get('qwen3-coder-480b')
|
||||
assert qwen_pricing is not None
|
||||
assert qwen_pricing['input_cost_per_million_tokens'] == 0.4
|
||||
assert qwen_pricing['output_cost_per_million_tokens'] == 1.6
|
||||
assert qwen_pricing['cached_input_cost_per_million_tokens'] is None # N/A
|
||||
|
||||
# Test other models against LiteLLM data
|
||||
for model_name, doc_pricing in docs_pricing.items():
|
||||
if model_name == 'qwen3-coder-480b':
|
||||
continue # Skip custom pricing model
|
||||
|
||||
litellm_pricing = self.get_litellm_pricing(model_name, pricing_data)
|
||||
|
||||
if litellm_pricing: # Only test if we found pricing in LiteLLM
|
||||
assert (
|
||||
abs(
|
||||
doc_pricing['input_cost_per_million_tokens']
|
||||
- litellm_pricing['input_cost_per_million_tokens']
|
||||
)
|
||||
< 0.01
|
||||
), (
|
||||
f'Input pricing mismatch for {model_name}: docs={doc_pricing["input_cost_per_million_tokens"]}, litellm={litellm_pricing["input_cost_per_million_tokens"]}'
|
||||
)
|
||||
|
||||
assert (
|
||||
abs(
|
||||
doc_pricing['output_cost_per_million_tokens']
|
||||
- litellm_pricing['output_cost_per_million_tokens']
|
||||
)
|
||||
< 0.01
|
||||
), (
|
||||
f'Output pricing mismatch for {model_name}: docs={doc_pricing["output_cost_per_million_tokens"]}, litellm={litellm_pricing["output_cost_per_million_tokens"]}'
|
||||
)
|
||||
|
||||
# Test cached input cost if both have it
|
||||
doc_cached = doc_pricing.get('cached_input_cost_per_million_tokens')
|
||||
litellm_cached = litellm_pricing.get(
|
||||
'cached_input_cost_per_million_tokens'
|
||||
)
|
||||
|
||||
if doc_cached is not None and litellm_cached is not None:
|
||||
assert abs(doc_cached - litellm_cached) < 0.01, (
|
||||
f'Cached input pricing mismatch for {model_name}: docs={doc_cached}, litellm={litellm_cached}'
|
||||
)
|
||||
elif doc_cached is None and litellm_cached is not None:
|
||||
# Documentation shows N/A but LiteLLM has cached pricing - this might be intentional
|
||||
pass
|
||||
elif doc_cached is not None and litellm_cached is None:
|
||||
# Documentation has cached pricing but LiteLLM doesn't - this shouldn't happen
|
||||
raise AssertionError(
|
||||
f'Documentation has cached pricing for {model_name} but LiteLLM does not'
|
||||
)
|
||||
|
||||
def test_all_openhands_models_documented(
|
||||
self, openhands_models: list[str], documentation_content: str
|
||||
):
|
||||
"""Test that all OpenHands models are documented in the pricing table."""
|
||||
docs_pricing = self.extract_pricing_from_docs(documentation_content)
|
||||
documented_models = set(docs_pricing.keys())
|
||||
|
||||
# Filter out models that might not have pricing (like kimi-k2-0711-preview)
|
||||
expected_models = set(openhands_models)
|
||||
|
||||
# Check that most models are documented (allowing for some models without pricing)
|
||||
documented_count = len(documented_models.intersection(expected_models))
|
||||
total_count = len(expected_models)
|
||||
|
||||
# We should have at least 80% of models documented
|
||||
coverage_ratio = documented_count / total_count if total_count > 0 else 0
|
||||
assert coverage_ratio >= 0.8, (
|
||||
f'Only {documented_count}/{total_count} models documented in pricing table'
|
||||
)
|
||||
|
||||
def test_model_list_consistency(
|
||||
self, openhands_models: list[str], documentation_content: str
|
||||
):
|
||||
"""Test that the model list in documentation is consistent with the code."""
|
||||
docs_pricing = self.extract_pricing_from_docs(documentation_content)
|
||||
documented_models = set(docs_pricing.keys())
|
||||
code_models = set(openhands_models)
|
||||
|
||||
# Find models that are in code but not in docs
|
||||
missing_from_docs = code_models - documented_models
|
||||
# Find models that are in docs but not in code
|
||||
extra_in_docs = documented_models - code_models
|
||||
|
||||
# Allow some models to be missing from docs (e.g., if they don't have pricing)
|
||||
# but no extra models should be in docs that aren't in code
|
||||
assert not extra_in_docs, (
|
||||
f'Models in documentation but not in code: {extra_in_docs}'
|
||||
)
|
||||
|
||||
# Report missing models for visibility (but don't fail the test)
|
||||
if missing_from_docs:
|
||||
print(f'Models in code but not documented: {missing_from_docs}')
|
||||
|
||||
def test_pricing_format_consistency(self, documentation_content: str):
|
||||
"""Test that pricing format is consistent in the documentation."""
|
||||
docs_pricing = self.extract_pricing_from_docs(documentation_content)
|
||||
|
||||
for model_name, pricing in docs_pricing.items():
|
||||
# Check that prices are reasonable (not negative, not extremely high)
|
||||
assert pricing['input_cost_per_million_tokens'] >= 0, (
|
||||
f'Negative input cost for {model_name}'
|
||||
)
|
||||
assert pricing['output_cost_per_million_tokens'] >= 0, (
|
||||
f'Negative output cost for {model_name}'
|
||||
)
|
||||
assert pricing['input_cost_per_million_tokens'] <= 100, (
|
||||
f'Unreasonably high input cost for {model_name}'
|
||||
)
|
||||
assert pricing['output_cost_per_million_tokens'] <= 200, (
|
||||
f'Unreasonably high output cost for {model_name}'
|
||||
)
|
||||
|
||||
# Output cost should generally be higher than input cost
|
||||
if pricing['input_cost_per_million_tokens'] > 0:
|
||||
ratio = (
|
||||
pricing['output_cost_per_million_tokens']
|
||||
/ pricing['input_cost_per_million_tokens']
|
||||
)
|
||||
assert ratio >= 1.0, (
|
||||
f'Output cost should be >= input cost for {model_name}'
|
||||
)
|
||||
assert ratio <= 20.0, (
|
||||
f'Output/input cost ratio too high for {model_name}'
|
||||
)
|
||||
Reference in New Issue
Block a user