Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- Dockerfile +81 -0
- README.md +275 -5
- __init__.py +16 -0
- client.py +101 -0
- inference.py +257 -0
- models.py +35 -0
- openenv.yaml +7 -0
- openenv_token_optimiser.egg-info/PKG-INFO +9 -0
- openenv_token_optimiser.egg-info/SOURCES.txt +15 -0
- openenv_token_optimiser.egg-info/dependency_links.txt +1 -0
- openenv_token_optimiser.egg-info/entry_points.txt +2 -0
- openenv_token_optimiser.egg-info/requires.txt +5 -0
- openenv_token_optimiser.egg-info/top_level.txt +1 -0
- pyproject.toml +45 -0
- server/__init__.py +11 -0
- server/app.py +85 -0
- server/requirements.txt +6 -0
- server/token_optimiser_environment.py +366 -0
- uv.lock +0 -0
Dockerfile
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
# Multi-stage build using openenv-base
|
| 8 |
+
# This Dockerfile is flexible and works for both:
|
| 9 |
+
# - In-repo environments (with local OpenEnv sources)
|
| 10 |
+
# - Standalone environments (with openenv from PyPI/Git)
|
| 11 |
+
# The build script (openenv build) handles context detection and sets appropriate build args.
|
| 12 |
+
|
| 13 |
+
ARG BASE_IMAGE=ghcr.io/meta-pytorch/openenv-base:latest
|
| 14 |
+
FROM ${BASE_IMAGE} AS builder
|
| 15 |
+
|
| 16 |
+
WORKDIR /app
|
| 17 |
+
|
| 18 |
+
# Ensure git is available (required for installing dependencies from VCS)
|
| 19 |
+
RUN apt-get update && \
|
| 20 |
+
apt-get install -y --no-install-recommends git && \
|
| 21 |
+
rm -rf /var/lib/apt/lists/*
|
| 22 |
+
|
| 23 |
+
# Build argument to control whether we're building standalone or in-repo
|
| 24 |
+
ARG BUILD_MODE=in-repo
|
| 25 |
+
ARG ENV_NAME=token_optimiser
|
| 26 |
+
|
| 27 |
+
# Copy environment code (always at root of build context)
|
| 28 |
+
COPY . /app/env
|
| 29 |
+
|
| 30 |
+
# For in-repo builds, openenv is already vendored in the build context
|
| 31 |
+
# For standalone builds, openenv will be installed via pyproject.toml
|
| 32 |
+
WORKDIR /app/env
|
| 33 |
+
|
| 34 |
+
# Ensure uv is available (for local builds where base image lacks it)
|
| 35 |
+
RUN if ! command -v uv >/dev/null 2>&1; then \
|
| 36 |
+
curl -LsSf https://astral.sh/uv/install.sh | sh && \
|
| 37 |
+
mv /root/.local/bin/uv /usr/local/bin/uv && \
|
| 38 |
+
mv /root/.local/bin/uvx /usr/local/bin/uvx; \
|
| 39 |
+
fi
|
| 40 |
+
|
| 41 |
+
# Install dependencies using uv sync
|
| 42 |
+
# If uv.lock exists, use it; otherwise resolve on the fly
|
| 43 |
+
RUN --mount=type=cache,target=/root/.cache/uv \
|
| 44 |
+
if [ -f uv.lock ]; then \
|
| 45 |
+
uv sync --frozen --no-install-project --no-editable; \
|
| 46 |
+
else \
|
| 47 |
+
uv sync --no-install-project --no-editable; \
|
| 48 |
+
fi
|
| 49 |
+
|
| 50 |
+
RUN --mount=type=cache,target=/root/.cache/uv \
|
| 51 |
+
if [ -f uv.lock ]; then \
|
| 52 |
+
uv sync --frozen --no-editable; \
|
| 53 |
+
else \
|
| 54 |
+
uv sync --no-editable; \
|
| 55 |
+
fi
|
| 56 |
+
|
| 57 |
+
# Final runtime stage
|
| 58 |
+
FROM ${BASE_IMAGE}
|
| 59 |
+
|
| 60 |
+
WORKDIR /app
|
| 61 |
+
|
| 62 |
+
# Copy the virtual environment from builder
|
| 63 |
+
COPY --from=builder /app/env/.venv /app/.venv
|
| 64 |
+
|
| 65 |
+
# Copy the environment code
|
| 66 |
+
COPY --from=builder /app/env /app/env
|
| 67 |
+
|
| 68 |
+
# Set PATH to use the virtual environment
|
| 69 |
+
ENV PATH="/app/.venv/bin:$PATH"
|
| 70 |
+
|
| 71 |
+
# Set PYTHONPATH so imports work correctly
|
| 72 |
+
ENV PYTHONPATH="/app/env:$PYTHONPATH"
|
| 73 |
+
|
| 74 |
+
# Health check
|
| 75 |
+
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
| 76 |
+
CMD curl -f http://localhost:8000/health || exit 1
|
| 77 |
+
|
| 78 |
+
# Run the FastAPI server
|
| 79 |
+
# The module path is constructed to work with the /app/env structure
|
| 80 |
+
ENV ENABLE_WEB_INTERFACE=true
|
| 81 |
+
CMD ["sh", "-c", "cd /app/env && uvicorn server.app:app --host 0.0.0.0 --port 8000"]
|
README.md
CHANGED
|
@@ -1,10 +1,280 @@
|
|
| 1 |
---
|
| 2 |
-
title: Token Optimiser
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Token Optimiser Environment
|
| 3 |
+
emoji: 🔤
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: purple
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 8000
|
| 8 |
pinned: false
|
| 9 |
+
base_path: /web
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# 🔤 Prompt & Response Token Optimization Environment
|
| 13 |
+
|
| 14 |
+
> An OpenEnv-compatible RL environment that trains agents to minimize LLM API token usage while preserving semantic quality — reducing AI inference costs at scale.
|
| 15 |
+
|
| 16 |
+
---
|
| 17 |
+
|
| 18 |
+
## 📌 Introduction
|
| 19 |
+
|
| 20 |
+
Large Language Model APIs charge per token. Verbose prompts and unconstrained responses waste tokens and money. This environment trains an AI agent to **rewrite verbose prompts into concise, efficient versions** that:
|
| 21 |
+
|
| 22 |
+
- Use fewer input tokens
|
| 23 |
+
- Guide the LLM toward shorter, correctly-formatted responses
|
| 24 |
+
- Preserve the full semantic meaning and intent of the original request
|
| 25 |
+
- Respect output format constraints (free text, bullet points, JSON)
|
| 26 |
+
|
| 27 |
+
The agent learns real-world prompt engineering — a critical skill for production LLM systems where cost efficiency matters at scale.
|
| 28 |
+
|
| 29 |
+
---
|
| 30 |
+
|
| 31 |
+
## 🏗️ Architecture
|
| 32 |
+
|
| 33 |
+
```
|
| 34 |
+
token_optimiser/
|
| 35 |
+
├── inference.py # Hackathon evaluation script
|
| 36 |
+
├── models.py # Pydantic data models (Action / Observation / State)
|
| 37 |
+
├── client.py # Async WebSocket EnvClient
|
| 38 |
+
├── openenv.yaml # OpenEnv deployment config
|
| 39 |
+
├── Dockerfile # Root-level multi-stage build
|
| 40 |
+
├── pyproject.toml # Package config & dependencies
|
| 41 |
+
├── uv.lock # Locked dependencies
|
| 42 |
+
└── server/
|
| 43 |
+
├── app.py # FastAPI server (WebSocket + HTTP)
|
| 44 |
+
├── token_optimiser_environment.py # Core RL environment logic
|
| 45 |
+
└── requirements.txt # Server dependencies
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
---
|
| 49 |
+
|
| 50 |
+
## 🧠 How It Works
|
| 51 |
+
|
| 52 |
+
### Action Space
|
| 53 |
+
The agent submits a **`TokenOptimiserAction`** containing its optimized version of the original prompt:
|
| 54 |
+
|
| 55 |
+
```python
|
| 56 |
+
class TokenOptimiserAction(Action):
|
| 57 |
+
optimized_prompt: str # Agent's rewritten, token-efficient prompt
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
### Observation Space
|
| 61 |
+
After each step, the agent receives a **`TokenOptimiserObservation`**:
|
| 62 |
+
|
| 63 |
+
```python
|
| 64 |
+
class TokenOptimiserObservation(Observation):
|
| 65 |
+
llm_response: str # Actual LLM response to the optimized prompt
|
| 66 |
+
input_tokens: int # Token count of the optimized prompt
|
| 67 |
+
output_tokens: int # Token count of the LLM response
|
| 68 |
+
reward: float # Step reward (0.0 – 1.0)
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
### State Space
|
| 72 |
+
```python
|
| 73 |
+
class TokenOptimiserState(State):
|
| 74 |
+
original_prompt: str # The verbose task prompt the agent must optimize
|
| 75 |
+
task_difficulty: str # "easy" | "medium" | "hard"
|
| 76 |
+
task_index: int # Index in task bank
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
---
|
| 80 |
+
|
| 81 |
+
## 📋 Tasks
|
| 82 |
+
|
| 83 |
+
### 🟢 Easy — Verbosity Reduction
|
| 84 |
+
**Original:** `"Can you please explain in a very detailed manner what machine learning is and how it works step by step?"`
|
| 85 |
+
**Goal:** Strip filler words, compress to core query
|
| 86 |
+
**Expected optimized:** `"Explain machine learning briefly."`
|
| 87 |
+
**Max output:** 50 tokens
|
| 88 |
+
|
| 89 |
+
### 🟡 Medium — Format Constraint Addition
|
| 90 |
+
**Original:** `"I need a comprehensive analysis of the renewable energy market trends over the past decade, including solar, wind, and hydroelectric power growth rates..."`
|
| 91 |
+
**Goal:** Compress input AND add explicit output format constraints
|
| 92 |
+
**Expected optimized:** `"Summarize 2013-2023 renewable energy trends: solar, wind, hydro. In 5 bullet points."`
|
| 93 |
+
**Max output:** 100 tokens
|
| 94 |
+
|
| 95 |
+
### 🔴 Hard — Multi-Intent Structured Output
|
| 96 |
+
**Original:** 82-word complex data science analysis request with 5 sub-tasks
|
| 97 |
+
**Goal:** Minimize total tokens (input + output) while producing structured JSON with all 5 required keys
|
| 98 |
+
**Expected optimized:** Compressed prompt specifying `JSON with keys: top_categories, growth_regions, responsive_segments, budget_allocation, risks_watch`
|
| 99 |
+
**Max output:** 200 tokens
|
| 100 |
+
|
| 101 |
+
---
|
| 102 |
+
|
| 103 |
+
## 🏆 Reward Function
|
| 104 |
+
|
| 105 |
+
**Hybrid grading** — combines token efficiency + LLM-as-judge semantic scoring:
|
| 106 |
+
|
| 107 |
+
| Component | Weight | Description |
|
| 108 |
+
|-----------|--------|-------------|
|
| 109 |
+
| Token Efficiency | 0.0 – 0.40 | Tokens saved vs. reference (input + output combined) |
|
| 110 |
+
| Semantic Quality | 0.0 – 0.30 | LLM judge rates response quality 0–10 |
|
| 111 |
+
| Format Compliance | 0.0 – 0.20 | Response matches required format (bullets / JSON / brief) |
|
| 112 |
+
| Length Penalty | −0.10 | Output exceeds 2× max token budget |
|
| 113 |
+
|
| 114 |
+
```
|
| 115 |
+
reward = token_efficiency + (semantic_score × 0.3) + format_score + length_penalty
|
| 116 |
+
reward = clamp(reward, 0.0, 1.0)
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
| Score | Meaning |
|
| 120 |
+
|-------|---------|
|
| 121 |
+
| 0.0 | Meaning lost, system failure, or no optimization |
|
| 122 |
+
| 0.3 – 0.5 | Token reduction achieved but quality degraded |
|
| 123 |
+
| 0.6 – 0.8 | Good balance of compression and quality |
|
| 124 |
+
| 0.9 – 1.0 | Optimal: minimal tokens, correct format, meaning preserved |
|
| 125 |
+
|
| 126 |
+
---
|
| 127 |
+
|
| 128 |
+
## ⚙️ Setup & Installation
|
| 129 |
+
|
| 130 |
+
### Prerequisites
|
| 131 |
+
- Python 3.10+
|
| 132 |
+
- [`uv`](https://github.com/astral-sh/uv) (recommended) or `pip`
|
| 133 |
+
- A Hugging Face account with a token that has **Inference Providers** permission
|
| 134 |
+
|
| 135 |
+
### 1. Clone and Install
|
| 136 |
+
|
| 137 |
+
```bash
|
| 138 |
+
git clone <your-repo-url>
|
| 139 |
+
cd token_optimiser
|
| 140 |
+
|
| 141 |
+
# Install with uv (recommended)
|
| 142 |
+
uv sync
|
| 143 |
+
|
| 144 |
+
# Or with pip
|
| 145 |
+
pip install -e .
|
| 146 |
+
```
|
| 147 |
+
|
| 148 |
+
### 2. Set Environment Variables
|
| 149 |
+
|
| 150 |
+
```bash
|
| 151 |
+
# Required
|
| 152 |
+
export HF_TOKEN="hf_your_token_here"
|
| 153 |
+
|
| 154 |
+
# Optional (these are the defaults)
|
| 155 |
+
export API_BASE_URL="https://router.huggingface.co/v1"
|
| 156 |
+
export MODEL_NAME="Qwen/Qwen2.5-72B-Instruct"
|
| 157 |
+
export SERVER_URL="http://localhost:8000"
|
| 158 |
+
```
|
| 159 |
+
|
| 160 |
+
On Windows (PowerShell):
|
| 161 |
+
```powershell
|
| 162 |
+
$env:HF_TOKEN = "hf_your_token_here"
|
| 163 |
+
```
|
| 164 |
+
|
| 165 |
+
> **HF Token Permissions:** Your token must have **"Make calls to Inference Providers"** enabled.
|
| 166 |
+
> Create/edit at → https://huggingface.co/settings/tokens
|
| 167 |
+
|
| 168 |
+
---
|
| 169 |
+
|
| 170 |
+
## 🚀 Running Locally
|
| 171 |
+
|
| 172 |
+
### Step 1 — Start the Environment Server
|
| 173 |
+
|
| 174 |
+
```bash
|
| 175 |
+
# Terminal 1
|
| 176 |
+
uv run uvicorn server.app:app --host 0.0.0.0 --port 8000
|
| 177 |
+
```
|
| 178 |
+
|
| 179 |
+
Expected output:
|
| 180 |
+
```
|
| 181 |
+
INFO: Application startup complete.
|
| 182 |
+
INFO: Uvicorn running on http://0.0.0.0:8000
|
| 183 |
+
```
|
| 184 |
+
|
| 185 |
+
### Step 2 — Run the Inference Script
|
| 186 |
+
|
| 187 |
+
```bash
|
| 188 |
+
# Terminal 2
|
| 189 |
+
uv run inference.py
|
| 190 |
+
```
|
| 191 |
+
|
| 192 |
+
Expected output:
|
| 193 |
+
```
|
| 194 |
+
[START] task=token_optimization env=token_optimiser model=Qwen/Qwen2.5-72B-Instruct
|
| 195 |
+
[STEP] step=1 action='Analyze renewable energy trends...' reward=0.62 done=false error=null
|
| 196 |
+
[STEP] step=2 action='Summarize 2013-2023 renewable energy...' reward=0.81 done=false error=null
|
| 197 |
+
[STEP] step=3 action='Renewable energy trends 2013-2023...' reward=0.84 done=false error=null
|
| 198 |
+
[STEP] step=4 action='Renewable energy 2013-2023 trends...' reward=0.82 done=false error=null
|
| 199 |
+
[STEP] step=5 action='Energy trends 2013-2023: solar, wind...' reward=0.81 done=true error=null
|
| 200 |
+
[END] success=true steps=5 score=0.780 rewards=0.62,0.81,0.84,0.82,0.81
|
| 201 |
+
```
|
| 202 |
+
|
| 203 |
+
### Step 3 — (Optional) Run via Docker
|
| 204 |
+
|
| 205 |
+
```bash
|
| 206 |
+
# Build
|
| 207 |
+
docker build -t token-optimiser-env .
|
| 208 |
+
|
| 209 |
+
# Run
|
| 210 |
+
docker run -p 8000:8000 \
|
| 211 |
+
-e HF_TOKEN=$HF_TOKEN \
|
| 212 |
+
-e API_BASE_URL=$API_BASE_URL \
|
| 213 |
+
token-optimiser-env
|
| 214 |
+
```
|
| 215 |
+
|
| 216 |
+
---
|
| 217 |
+
|
| 218 |
+
## 🐍 Using the Python Client
|
| 219 |
+
|
| 220 |
+
```python
|
| 221 |
+
import asyncio
|
| 222 |
+
from token_optimiser import TokenOptimiserEnv, TokenOptimiserAction
|
| 223 |
+
|
| 224 |
+
async def main():
|
| 225 |
+
async with TokenOptimiserEnv(base_url="http://localhost:8000") as env:
|
| 226 |
+
# Reset — get the task
|
| 227 |
+
result = await env.reset()
|
| 228 |
+
state = await env.state()
|
| 229 |
+
print(f"Task: {state.original_prompt}")
|
| 230 |
+
print(f"Difficulty: {state.task_difficulty}")
|
| 231 |
+
|
| 232 |
+
# Agent submits an optimized prompt
|
| 233 |
+
result = await env.step(
|
| 234 |
+
TokenOptimiserAction(optimized_prompt="Explain machine learning briefly.")
|
| 235 |
+
)
|
| 236 |
+
print(f"LLM Response: {result.observation.llm_response}")
|
| 237 |
+
print(f"Reward: {result.reward}")
|
| 238 |
+
print(f"Tokens — in: {result.observation.input_tokens}, out: {result.observation.output_tokens}")
|
| 239 |
+
|
| 240 |
+
asyncio.run(main())
|
| 241 |
+
```
|
| 242 |
+
|
| 243 |
+
---
|
| 244 |
+
|
| 245 |
+
## ☁️ Deployment
|
| 246 |
+
|
| 247 |
+
Deploy to Hugging Face Spaces using the OpenEnv CLI:
|
| 248 |
+
|
| 249 |
+
```bash
|
| 250 |
+
openenv push
|
| 251 |
+
```
|
| 252 |
+
|
| 253 |
+
---
|
| 254 |
+
|
| 255 |
+
## 🔧 Environment Variables Reference
|
| 256 |
+
|
| 257 |
+
| Variable | Default | Description |
|
| 258 |
+
|----------|---------|-------------|
|
| 259 |
+
| `HF_TOKEN` | *(required)* | Hugging Face API key with Inference Providers permission |
|
| 260 |
+
| `API_BASE_URL` | `https://router.huggingface.co/v1` | LLM endpoint URL |
|
| 261 |
+
| `MODEL_NAME` | `Qwen/Qwen2.5-72B-Instruct` | Model used for responses and judging |
|
| 262 |
+
| `SERVER_URL` | `http://localhost:8000` | Environment server URL (for inference.py) |
|
| 263 |
+
| `LOCAL_IMAGE_NAME` | *(optional)* | Docker image name — auto-spins container if set |
|
| 264 |
+
|
| 265 |
+
---
|
| 266 |
+
|
| 267 |
+
## 📦 Dependencies
|
| 268 |
+
|
| 269 |
+
- [`openenv-core`](https://github.com/meta-pytorch/OpenEnv) ≥ 0.2.2 — RL environment framework
|
| 270 |
+
- `openai` — LLM API client (routed through HF)
|
| 271 |
+
- `fastapi` + `uvicorn` — Environment server
|
| 272 |
+
- `pydantic` v2 — Data model validation
|
| 273 |
+
|
| 274 |
+
See [`pyproject.toml`](./pyproject.toml) for the full pinned dependency list.
|
| 275 |
+
|
| 276 |
+
---
|
| 277 |
+
|
| 278 |
+
## 📄 License
|
| 279 |
+
|
| 280 |
+
BSD License — see source files for details.
|
__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
"""Token Optimiser Environment."""
|
| 8 |
+
|
| 9 |
+
from .client import TokenOptimiserEnv
|
| 10 |
+
from .models import TokenOptimiserAction, TokenOptimiserObservation
|
| 11 |
+
|
| 12 |
+
__all__ = [
|
| 13 |
+
"TokenOptimiserAction",
|
| 14 |
+
"TokenOptimiserObservation",
|
| 15 |
+
"TokenOptimiserEnv",
|
| 16 |
+
]
|
client.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# source tree.
|
| 6 |
+
|
| 7 |
+
"""Token Optimiser Environment Client."""
|
| 8 |
+
|
| 9 |
+
from typing import Dict
|
| 10 |
+
|
| 11 |
+
from openenv.core import EnvClient
|
| 12 |
+
from openenv.core.client_types import StepResult
|
| 13 |
+
from openenv.core.env_server.types import State
|
| 14 |
+
|
| 15 |
+
from .models import TokenOptimiserAction, TokenOptimiserObservation, TokenOptimiserState
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class TokenOptimiserEnv(
|
| 19 |
+
EnvClient[TokenOptimiserAction, TokenOptimiserObservation, TokenOptimiserState]
|
| 20 |
+
):
|
| 21 |
+
"""
|
| 22 |
+
Client for the Token Optimiser Environment.
|
| 23 |
+
|
| 24 |
+
This client maintains a persistent WebSocket connection to the environment server,
|
| 25 |
+
enabling efficient multi-step interactions with lower latency.
|
| 26 |
+
Each client instance has its own dedicated environment session on the server.
|
| 27 |
+
|
| 28 |
+
Example:
|
| 29 |
+
>>> # Connect to a running server
|
| 30 |
+
>>> with TokenOptimiserEnv(base_url="http://localhost:8000") as client:
|
| 31 |
+
... result = client.reset()
|
| 32 |
+
... print(result.observation.llm_response)
|
| 33 |
+
...
|
| 34 |
+
... result = client.step(TokenOptimiserAction(optimized_prompt="Explain ML briefly"))
|
| 35 |
+
... print(result.observation.llm_response)
|
| 36 |
+
|
| 37 |
+
Example with Docker:
|
| 38 |
+
>>> # Automatically start container and connect
|
| 39 |
+
>>> client = TokenOptimiserEnv.from_docker_image("token_optimiser-env:latest")
|
| 40 |
+
>>> try:
|
| 41 |
+
... result = client.reset()
|
| 42 |
+
... result = client.step(TokenOptimiserAction(optimized_prompt="Test prompt"))
|
| 43 |
+
... finally:
|
| 44 |
+
... client.close()
|
| 45 |
+
"""
|
| 46 |
+
|
| 47 |
+
def _step_payload(self, action: TokenOptimiserAction) -> Dict:
|
| 48 |
+
"""
|
| 49 |
+
Convert TokenOptimiserAction to JSON payload for step message.
|
| 50 |
+
|
| 51 |
+
Args:
|
| 52 |
+
action: TokenOptimiserAction instance
|
| 53 |
+
|
| 54 |
+
Returns:
|
| 55 |
+
Dictionary representation suitable for JSON encoding
|
| 56 |
+
"""
|
| 57 |
+
return {
|
| 58 |
+
"optimized_prompt": action.optimized_prompt,
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
def _parse_result(self, payload: Dict) -> StepResult[TokenOptimiserObservation]:
|
| 62 |
+
"""
|
| 63 |
+
Parse server response into StepResult[TokenOptimiserObservation].
|
| 64 |
+
|
| 65 |
+
Args:
|
| 66 |
+
payload: JSON response data from server
|
| 67 |
+
|
| 68 |
+
Returns:
|
| 69 |
+
StepResult with TokenOptimiserObservation
|
| 70 |
+
"""
|
| 71 |
+
obs_data = payload.get("observation", {})
|
| 72 |
+
observation = TokenOptimiserObservation(
|
| 73 |
+
llm_response=obs_data.get("llm_response", ""),
|
| 74 |
+
input_tokens=obs_data.get("input_tokens", 0),
|
| 75 |
+
output_tokens=obs_data.get("output_tokens", 0),
|
| 76 |
+
reward=obs_data.get("reward", 0.0),
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
return StepResult(
|
| 80 |
+
observation=observation,
|
| 81 |
+
reward=payload.get("reward", 0.0),
|
| 82 |
+
done=payload.get("done", False),
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
def _parse_state(self, payload: Dict) -> TokenOptimiserState:
|
| 86 |
+
"""
|
| 87 |
+
Parse server response into TokenOptimiserState object.
|
| 88 |
+
|
| 89 |
+
Args:
|
| 90 |
+
payload: JSON response from state request
|
| 91 |
+
|
| 92 |
+
Returns:
|
| 93 |
+
TokenOptimiserState object with episode information
|
| 94 |
+
"""
|
| 95 |
+
return TokenOptimiserState(
|
| 96 |
+
episode_id=payload.get("episode_id"),
|
| 97 |
+
step_count=payload.get("step_count", 0),
|
| 98 |
+
original_prompt=payload.get("original_prompt", ""),
|
| 99 |
+
task_difficulty=payload.get("task_difficulty", "easy"),
|
| 100 |
+
task_index=payload.get("task_index", 0)
|
| 101 |
+
)
|
inference.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Inference Script — Token Optimiser Environment
|
| 3 |
+
================================================
|
| 4 |
+
STDOUT FORMAT (mandatory):
|
| 5 |
+
[START] task=<task> env=<env> model=<model>
|
| 6 |
+
[STEP] step=<n> action=<str> reward=<0.00> done=<true|false> error=<msg|null>
|
| 7 |
+
[END] success=<true|false> steps=<n> score=<0.000> rewards=<r1,r2,...>
|
| 8 |
+
|
| 9 |
+
Environment variables required:
|
| 10 |
+
HF_TOKEN — Hugging Face API key
|
| 11 |
+
API_BASE_URL — LLM endpoint (default: https://router.huggingface.co/v1)
|
| 12 |
+
MODEL_NAME — Model id (default: Qwen/Qwen2.5-72B-Instruct)
|
| 13 |
+
SERVER_URL — Running env server (default: http://localhost:8000)
|
| 14 |
+
LOCAL_IMAGE_NAME — Docker image name (optional; spins up container if set)
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import asyncio
|
| 18 |
+
import os
|
| 19 |
+
import textwrap
|
| 20 |
+
from typing import List, Optional
|
| 21 |
+
|
| 22 |
+
from openai import OpenAI
|
| 23 |
+
|
| 24 |
+
from token_optimiser import TokenOptimiserEnv, TokenOptimiserAction
|
| 25 |
+
|
| 26 |
+
# ---------------------------------------------------------------------------
|
| 27 |
+
# Configuration
|
| 28 |
+
# ---------------------------------------------------------------------------
|
| 29 |
+
API_BASE_URL: str = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
|
| 30 |
+
MODEL_NAME: str = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct")
|
| 31 |
+
HF_TOKEN: Optional[str] = os.getenv("HF_TOKEN") or os.getenv("API_KEY")
|
| 32 |
+
SERVER_URL: str = os.getenv("SERVER_URL", "http://localhost:8000")
|
| 33 |
+
LOCAL_IMAGE_NAME: Optional[str] = os.getenv("LOCAL_IMAGE_NAME")
|
| 34 |
+
|
| 35 |
+
TASK_NAME: str = "token_optimization"
|
| 36 |
+
BENCHMARK: str = "token_optimiser"
|
| 37 |
+
MAX_STEPS: int = 5
|
| 38 |
+
TEMPERATURE: float = 0.3
|
| 39 |
+
MAX_TOKENS: int = 200
|
| 40 |
+
SUCCESS_THRESHOLD: float = 0.6
|
| 41 |
+
|
| 42 |
+
SYSTEM_PROMPT = textwrap.dedent("""
|
| 43 |
+
You are a prompt optimization expert. Rewrite the given prompt to:
|
| 44 |
+
1. Use the fewest possible tokens (concise language, no filler words)
|
| 45 |
+
2. Preserve full semantic meaning and intent
|
| 46 |
+
3. Add explicit output-format constraints (e.g., "in 5 bullet points", "as JSON with keys: …")
|
| 47 |
+
4. Guide the responder toward a shorter, precise answer
|
| 48 |
+
|
| 49 |
+
Reply with ONLY the optimized prompt — no explanations, no prefixes, no quotes.
|
| 50 |
+
""").strip()
|
| 51 |
+
|
| 52 |
+
# ---------------------------------------------------------------------------
|
| 53 |
+
# Logging helpers (mandatory format)
|
| 54 |
+
# ---------------------------------------------------------------------------
|
| 55 |
+
|
| 56 |
+
def log_start(task: str, env: str, model: str) -> None:
|
| 57 |
+
print(f"[START] task={task} env={env} model={model}", flush=True)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def log_step(step: int, action: str, reward: float, done: bool, error: Optional[str]) -> None:
|
| 61 |
+
# Truncate action for readability but keep it on one line
|
| 62 |
+
action_short = action.replace("\n", " ")[:120]
|
| 63 |
+
error_val = error if error else "null"
|
| 64 |
+
print(
|
| 65 |
+
f"[STEP] step={step} action={action_short!r} "
|
| 66 |
+
f"reward={reward:.2f} done={str(done).lower()} error={error_val}",
|
| 67 |
+
flush=True,
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def log_end(success: bool, steps: int, score: float, rewards: List[float]) -> None:
|
| 72 |
+
rewards_str = ",".join(f"{r:.2f}" for r in rewards)
|
| 73 |
+
print(
|
| 74 |
+
f"[END] success={str(success).lower()} steps={steps} "
|
| 75 |
+
f"score={score:.3f} rewards={rewards_str}",
|
| 76 |
+
flush=True,
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# ---------------------------------------------------------------------------
|
| 81 |
+
# LLM helpers
|
| 82 |
+
# ---------------------------------------------------------------------------
|
| 83 |
+
|
| 84 |
+
def _build_user_message(original_prompt: str, step: int,
|
| 85 |
+
prev_reward: float, prev_response: str,
|
| 86 |
+
history: List[str]) -> str:
|
| 87 |
+
if step == 1:
|
| 88 |
+
return (
|
| 89 |
+
f"Optimize this prompt to minimize tokens while preserving all meaning:\n\n"
|
| 90 |
+
f"{original_prompt}"
|
| 91 |
+
)
|
| 92 |
+
history_block = "\n".join(history[-3:]) if history else "None"
|
| 93 |
+
return textwrap.dedent(f"""
|
| 94 |
+
Original prompt:
|
| 95 |
+
{original_prompt}
|
| 96 |
+
|
| 97 |
+
Your last optimized version got reward: {prev_reward:.2f}
|
| 98 |
+
LLM responded with: {prev_response!r}
|
| 99 |
+
|
| 100 |
+
Recent history:
|
| 101 |
+
{history_block}
|
| 102 |
+
|
| 103 |
+
Improve your optimization further. Reply with ONLY the new optimized prompt.
|
| 104 |
+
""").strip()
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def get_optimized_prompt(
|
| 108 |
+
llm: OpenAI,
|
| 109 |
+
original_prompt: str,
|
| 110 |
+
step: int,
|
| 111 |
+
prev_reward: float,
|
| 112 |
+
prev_response: str,
|
| 113 |
+
history: List[str],
|
| 114 |
+
) -> str:
|
| 115 |
+
user_msg = _build_user_message(original_prompt, step, prev_reward, prev_response, history)
|
| 116 |
+
try:
|
| 117 |
+
completion = llm.chat.completions.create(
|
| 118 |
+
model=MODEL_NAME,
|
| 119 |
+
messages=[
|
| 120 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 121 |
+
{"role": "user", "content": user_msg},
|
| 122 |
+
],
|
| 123 |
+
temperature=TEMPERATURE,
|
| 124 |
+
max_tokens=MAX_TOKENS,
|
| 125 |
+
)
|
| 126 |
+
result = (completion.choices[0].message.content or "").strip()
|
| 127 |
+
return result if result else "Explain briefly."
|
| 128 |
+
except Exception as exc:
|
| 129 |
+
print(f"[DEBUG] LLM call failed: {exc}", flush=True)
|
| 130 |
+
return _rule_based_compress(original_prompt, step)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
# Rule-based fallback compressor (used when LLM is unavailable)
|
| 134 |
+
_FILLER = {
|
| 135 |
+
"please", "kindly", "could", "you", "can", "i", "need", "want", "would",
|
| 136 |
+
"like", "very", "really", "just", "actually", "basically", "specifically",
|
| 137 |
+
"a", "an", "the", "in", "of", "to", "and", "that", "is", "are", "be",
|
| 138 |
+
"will", "should", "must", "have", "has", "do", "does", "for", "with",
|
| 139 |
+
"as", "at", "by", "on", "or", "but", "it", "its", "this",
|
| 140 |
+
}
|
| 141 |
+
_BREVITY = [
|
| 142 |
+
"", # step 1 — just strip fillers
|
| 143 |
+
" Be brief.", # step 2
|
| 144 |
+
" Limit response to 3 sentences.", # step 3
|
| 145 |
+
" Reply in one sentence.", # step 4+
|
| 146 |
+
]
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _rule_based_compress(original_prompt: str, step: int = 1) -> str:
|
| 150 |
+
"""Strip filler words and add a conciseness constraint."""
|
| 151 |
+
words = original_prompt.split()
|
| 152 |
+
compressed = [
|
| 153 |
+
w for w in words
|
| 154 |
+
if w.lower().rstrip(".,?!") not in _FILLER
|
| 155 |
+
]
|
| 156 |
+
suffix = _BREVITY[min(step - 1, len(_BREVITY) - 1)]
|
| 157 |
+
result = " ".join(compressed) + suffix
|
| 158 |
+
return result if result.strip() else original_prompt
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
# ---------------------------------------------------------------------------
|
| 162 |
+
# Main episode loop
|
| 163 |
+
# ---------------------------------------------------------------------------
|
| 164 |
+
|
| 165 |
+
async def run_episode(llm: OpenAI) -> None:
|
| 166 |
+
rewards: List[float] = []
|
| 167 |
+
steps_taken = 0
|
| 168 |
+
score = 0.0
|
| 169 |
+
success = False
|
| 170 |
+
|
| 171 |
+
log_start(task=TASK_NAME, env=BENCHMARK, model=MODEL_NAME)
|
| 172 |
+
|
| 173 |
+
# Connect to environment
|
| 174 |
+
if LOCAL_IMAGE_NAME:
|
| 175 |
+
env = await TokenOptimiserEnv.from_docker_image(LOCAL_IMAGE_NAME)
|
| 176 |
+
else:
|
| 177 |
+
env = TokenOptimiserEnv(base_url=SERVER_URL)
|
| 178 |
+
await env.connect()
|
| 179 |
+
|
| 180 |
+
try:
|
| 181 |
+
# Reset — get initial observation
|
| 182 |
+
reset_result = await env.reset()
|
| 183 |
+
|
| 184 |
+
# Fetch original prompt from server state
|
| 185 |
+
env_state = await env.state()
|
| 186 |
+
original_prompt: str = env_state.original_prompt or "Explain machine learning briefly."
|
| 187 |
+
|
| 188 |
+
print(
|
| 189 |
+
f"[DEBUG] Task difficulty={env_state.task_difficulty} | "
|
| 190 |
+
f"Original prompt ({len(original_prompt.split())} words): {original_prompt[:80]}...",
|
| 191 |
+
flush=True,
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
prev_reward = 0.0
|
| 195 |
+
prev_response = ""
|
| 196 |
+
history: List[str] = []
|
| 197 |
+
|
| 198 |
+
for step in range(1, MAX_STEPS + 1):
|
| 199 |
+
# Ask LLM to optimize the prompt
|
| 200 |
+
optimized = get_optimized_prompt(
|
| 201 |
+
llm, original_prompt, step, prev_reward, prev_response, history
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
# Step the environment with the optimized prompt
|
| 205 |
+
error_msg: Optional[str] = None
|
| 206 |
+
reward = 0.0
|
| 207 |
+
done = False
|
| 208 |
+
try:
|
| 209 |
+
result = await env.step(TokenOptimiserAction(optimized_prompt=optimized))
|
| 210 |
+
obs = result.observation
|
| 211 |
+
reward = result.reward # server puts reward at top-level, not inside obs
|
| 212 |
+
done = result.done or (step >= MAX_STEPS)
|
| 213 |
+
prev_response = obs.llm_response
|
| 214 |
+
print(
|
| 215 |
+
f"[DEBUG] tokens in={obs.input_tokens} out={obs.output_tokens}",
|
| 216 |
+
flush=True,
|
| 217 |
+
)
|
| 218 |
+
except Exception as exc:
|
| 219 |
+
error_msg = str(exc)
|
| 220 |
+
done = True
|
| 221 |
+
|
| 222 |
+
rewards.append(reward)
|
| 223 |
+
steps_taken = step
|
| 224 |
+
prev_reward = reward
|
| 225 |
+
history.append(f"step={step} prompt={optimized!r:.60} reward={reward:.2f}")
|
| 226 |
+
|
| 227 |
+
log_step(step=step, action=optimized, reward=reward, done=done, error=error_msg)
|
| 228 |
+
|
| 229 |
+
if done:
|
| 230 |
+
break
|
| 231 |
+
|
| 232 |
+
# Score = average reward across steps, clamped to [0, 1]
|
| 233 |
+
score = sum(rewards) / len(rewards) if rewards else 0.0
|
| 234 |
+
score = max(0.0, min(1.0, score))
|
| 235 |
+
success = score >= SUCCESS_THRESHOLD
|
| 236 |
+
|
| 237 |
+
except Exception as exc:
|
| 238 |
+
print(f"[DEBUG] Episode error: {exc}", flush=True)
|
| 239 |
+
finally:
|
| 240 |
+
try:
|
| 241 |
+
await env.close()
|
| 242 |
+
except Exception:
|
| 243 |
+
pass
|
| 244 |
+
log_end(success=success, steps=steps_taken, score=score, rewards=rewards)
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
async def main() -> None:
|
| 248 |
+
if not HF_TOKEN:
|
| 249 |
+
print("[ERROR] HF_TOKEN environment variable not set. Exiting.", flush=True)
|
| 250 |
+
return
|
| 251 |
+
|
| 252 |
+
llm = OpenAI(base_url=API_BASE_URL, api_key=HF_TOKEN)
|
| 253 |
+
await run_episode(llm)
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
if __name__ == "__main__":
|
| 257 |
+
asyncio.run(main())
|
models.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# source tree.
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
Data models for the Prompt & Response Token Optimization Environment.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from openenv.core.env_server.types import Action, Observation, State
|
| 12 |
+
from pydantic import Field
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class TokenOptimiserAction(Action):
|
| 16 |
+
"""Action for the Token Optimiser environment - the optimized prompt."""
|
| 17 |
+
|
| 18 |
+
optimized_prompt: str = Field(..., description="The optimized prompt sent to the LLM")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class TokenOptimiserObservation(Observation):
|
| 22 |
+
"""Observation from the Token Optimiser environment - LLM response and token metrics."""
|
| 23 |
+
|
| 24 |
+
llm_response: str = Field(default="", description="The response from the LLM")
|
| 25 |
+
input_tokens: int = Field(default=0, description="Number of tokens in the optimized prompt")
|
| 26 |
+
output_tokens: int = Field(default=0, description="Number of tokens in the LLM response")
|
| 27 |
+
reward: float = Field(default=0.0, description="Reward score for this step (0.0-1.0)")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class TokenOptimiserState(State):
|
| 31 |
+
"""State for the Token Optimiser environment."""
|
| 32 |
+
|
| 33 |
+
original_prompt: str = Field(default="", description="The original user prompt/task")
|
| 34 |
+
task_difficulty: str = Field(default="easy", description="Current task difficulty: easy/medium/hard")
|
| 35 |
+
task_index: int = Field(default=0, description="Index of current task in task bank")
|
openenv.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
spec_version: 1
|
| 2 |
+
name: token_optimiser
|
| 3 |
+
type: space
|
| 4 |
+
runtime: fastapi
|
| 5 |
+
app: server.app:app
|
| 6 |
+
port: 8000
|
| 7 |
+
|
openenv_token_optimiser.egg-info/PKG-INFO
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Metadata-Version: 2.4
|
| 2 |
+
Name: openenv-token_optimiser
|
| 3 |
+
Version: 0.1.0
|
| 4 |
+
Summary: Token Optimiser environment for OpenEnv
|
| 5 |
+
Requires-Python: >=3.10
|
| 6 |
+
Requires-Dist: openenv-core[core]>=0.2.2
|
| 7 |
+
Provides-Extra: dev
|
| 8 |
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
| 9 |
+
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
openenv_token_optimiser.egg-info/SOURCES.txt
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
README.md
|
| 2 |
+
pyproject.toml
|
| 3 |
+
./__init__.py
|
| 4 |
+
./client.py
|
| 5 |
+
./inference.py
|
| 6 |
+
./models.py
|
| 7 |
+
openenv_token_optimiser.egg-info/PKG-INFO
|
| 8 |
+
openenv_token_optimiser.egg-info/SOURCES.txt
|
| 9 |
+
openenv_token_optimiser.egg-info/dependency_links.txt
|
| 10 |
+
openenv_token_optimiser.egg-info/entry_points.txt
|
| 11 |
+
openenv_token_optimiser.egg-info/requires.txt
|
| 12 |
+
openenv_token_optimiser.egg-info/top_level.txt
|
| 13 |
+
server/__init__.py
|
| 14 |
+
server/app.py
|
| 15 |
+
server/token_optimiser_environment.py
|
openenv_token_optimiser.egg-info/dependency_links.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
openenv_token_optimiser.egg-info/entry_points.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[console_scripts]
|
| 2 |
+
server = token_optimiser.server.app:main
|
openenv_token_optimiser.egg-info/requires.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
openenv-core[core]>=0.2.2
|
| 2 |
+
|
| 3 |
+
[dev]
|
| 4 |
+
pytest>=8.0.0
|
| 5 |
+
pytest-cov>=4.0.0
|
openenv_token_optimiser.egg-info/top_level.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
token_optimiser
|
pyproject.toml
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
[build-system]
|
| 8 |
+
requires = ["setuptools>=45", "wheel"]
|
| 9 |
+
build-backend = "setuptools.build_meta"
|
| 10 |
+
|
| 11 |
+
[project]
|
| 12 |
+
name = "openenv-token_optimiser"
|
| 13 |
+
version = "0.1.0"
|
| 14 |
+
description = "Token Optimiser environment for OpenEnv"
|
| 15 |
+
requires-python = ">=3.10"
|
| 16 |
+
dependencies = [
|
| 17 |
+
# Core OpenEnv runtime (provides FastAPI server + HTTP client types)
|
| 18 |
+
# install from github
|
| 19 |
+
# "openenv-core[core] @ git+https://github.com/meta-pytorch/OpenEnv.git",
|
| 20 |
+
"openenv-core[core]>=0.2.2",
|
| 21 |
+
# Environment-specific dependencies
|
| 22 |
+
# Add all dependencies needed for your environment here
|
| 23 |
+
# Examples:
|
| 24 |
+
# "numpy>=1.19.0",
|
| 25 |
+
# "torch>=2.0.0",
|
| 26 |
+
# "gymnasium>=0.29.0",
|
| 27 |
+
# "openspiel>=1.0.0",
|
| 28 |
+
# "smolagents>=1.22.0,<2",
|
| 29 |
+
]
|
| 30 |
+
|
| 31 |
+
[project.optional-dependencies]
|
| 32 |
+
dev = [
|
| 33 |
+
"pytest>=8.0.0",
|
| 34 |
+
"pytest-cov>=4.0.0",
|
| 35 |
+
]
|
| 36 |
+
|
| 37 |
+
[project.scripts]
|
| 38 |
+
# Server entry point - enables running via: uv run --project . server
|
| 39 |
+
# or: python -m token_optimiser.server.app
|
| 40 |
+
server = "token_optimiser.server.app:main"
|
| 41 |
+
|
| 42 |
+
[tool.setuptools]
|
| 43 |
+
include-package-data = true
|
| 44 |
+
packages = ["token_optimiser", "token_optimiser.server"]
|
| 45 |
+
package-dir = { "token_optimiser" = ".", "token_optimiser.server" = "server" }
|
server/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
"""Token Optimiser environment server components."""
|
| 8 |
+
|
| 9 |
+
from .token_optimiser_environment import TokenOptimiserEnvironment
|
| 10 |
+
|
| 11 |
+
__all__ = ["TokenOptimiserEnvironment"]
|
server/app.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# source tree.
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
FastAPI application for the Token Optimiser Environment.
|
| 9 |
+
|
| 10 |
+
This module creates an HTTP server that exposes the TokenOptimiserEnvironment
|
| 11 |
+
over HTTP and WebSocket endpoints, compatible with EnvClient.
|
| 12 |
+
|
| 13 |
+
Endpoints:
|
| 14 |
+
- POST /reset: Reset the environment
|
| 15 |
+
- POST /step: Execute an action
|
| 16 |
+
- GET /state: Get current environment state
|
| 17 |
+
- GET /schema: Get action/observation schemas
|
| 18 |
+
- WS /ws: WebSocket endpoint for persistent sessions
|
| 19 |
+
|
| 20 |
+
Usage:
|
| 21 |
+
# Development (with auto-reload):
|
| 22 |
+
uvicorn server.app:app --reload --host 0.0.0.0 --port 8000
|
| 23 |
+
|
| 24 |
+
# Production:
|
| 25 |
+
uvicorn server.app:app --host 0.0.0.0 --port 8000 --workers 4
|
| 26 |
+
|
| 27 |
+
# Or run directly:
|
| 28 |
+
python -m server.app
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
try:
|
| 32 |
+
from openenv.core.env_server.http_server import create_app
|
| 33 |
+
except Exception as e: # pragma: no cover
|
| 34 |
+
raise ImportError(
|
| 35 |
+
"openenv is required for the web interface. Install dependencies with '\n uv sync\n'"
|
| 36 |
+
) from e
|
| 37 |
+
|
| 38 |
+
# Import using absolute paths from package root
|
| 39 |
+
import sys
|
| 40 |
+
import os
|
| 41 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
| 42 |
+
|
| 43 |
+
from models import TokenOptimiserAction, TokenOptimiserObservation, TokenOptimiserState
|
| 44 |
+
from server.token_optimiser_environment import TokenOptimiserEnvironment
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# Create the app with web interface and README integration
|
| 48 |
+
app = create_app(
|
| 49 |
+
TokenOptimiserEnvironment,
|
| 50 |
+
TokenOptimiserAction,
|
| 51 |
+
TokenOptimiserObservation,
|
| 52 |
+
env_name="token_optimiser",
|
| 53 |
+
max_concurrent_envs=10, # Increased for RL training concurrency
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def main(host: str = "0.0.0.0", port: int = 8000):
|
| 58 |
+
"""
|
| 59 |
+
Entry point for direct execution via uv run or python -m.
|
| 60 |
+
|
| 61 |
+
This function enables running the server without Docker:
|
| 62 |
+
uv run --project . server
|
| 63 |
+
uv run --project . server --port 8001
|
| 64 |
+
python -m token_optimiser.server.app
|
| 65 |
+
|
| 66 |
+
Args:
|
| 67 |
+
host: Host address to bind to (default: "0.0.0.0")
|
| 68 |
+
port: Port number to listen on (default: 8000)
|
| 69 |
+
|
| 70 |
+
For production deployments, consider using uvicorn directly with
|
| 71 |
+
multiple workers:
|
| 72 |
+
uvicorn token_optimiser.server.app:app --workers 4
|
| 73 |
+
"""
|
| 74 |
+
import uvicorn
|
| 75 |
+
|
| 76 |
+
uvicorn.run(app, host=host, port=port)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
if __name__ == "__main__":
|
| 80 |
+
import argparse
|
| 81 |
+
|
| 82 |
+
parser = argparse.ArgumentParser()
|
| 83 |
+
parser.add_argument("--port", type=int, default=8000)
|
| 84 |
+
args = parser.parse_args()
|
| 85 |
+
main(port=args.port)
|
server/requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
openenv[core]>=0.2.0
|
| 2 |
+
fastapi>=0.115.0
|
| 3 |
+
uvicorn>=0.24.0
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
|
server/token_optimiser_environment.py
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# source tree.
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
Prompt & Response Token Optimization Environment Implementation.
|
| 9 |
+
|
| 10 |
+
A sandboxed LLM interaction environment where an AI agent optimizes both input prompts
|
| 11 |
+
and expected output responses to minimize total token usage while maintaining correctness.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import os
|
| 15 |
+
import random
|
| 16 |
+
from uuid import uuid4
|
| 17 |
+
|
| 18 |
+
try:
|
| 19 |
+
from openai import OpenAI
|
| 20 |
+
except ImportError:
|
| 21 |
+
OpenAI = None
|
| 22 |
+
|
| 23 |
+
from openenv.core.env_server.interfaces import Environment
|
| 24 |
+
from openenv.core.env_server.types import State
|
| 25 |
+
|
| 26 |
+
try:
|
| 27 |
+
from ..models import TokenOptimiserAction, TokenOptimiserObservation, TokenOptimiserState
|
| 28 |
+
except ImportError:
|
| 29 |
+
from models import TokenOptimiserAction, TokenOptimiserObservation, TokenOptimiserState
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class TokenOptimiserEnvironment(Environment):
|
| 33 |
+
"""
|
| 34 |
+
Prompt & Response Token Optimization Environment.
|
| 35 |
+
|
| 36 |
+
The agent receives a user prompt/task and must optimize it to reduce token usage
|
| 37 |
+
while guiding the LLM to produce correct, properly formatted responses.
|
| 38 |
+
"""
|
| 39 |
+
|
| 40 |
+
# Enable concurrent WebSocket sessions - REQUIRED for RL training
|
| 41 |
+
SUPPORTS_CONCURRENT_SESSIONS: bool = True
|
| 42 |
+
|
| 43 |
+
def __init__(self):
|
| 44 |
+
"""Initialize the token optimization environment."""
|
| 45 |
+
self._state = TokenOptimiserState(episode_id=str(uuid4()), step_count=0)
|
| 46 |
+
self._task_bank = self._load_task_bank()
|
| 47 |
+
self._current_task = None
|
| 48 |
+
self._reset_count = 0
|
| 49 |
+
|
| 50 |
+
# Hybrid LLM client — reads credentials from env vars at startup
|
| 51 |
+
api_key = os.getenv("HF_TOKEN") or os.getenv("API_KEY")
|
| 52 |
+
api_base = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
|
| 53 |
+
self._model = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct")
|
| 54 |
+
if OpenAI and api_key:
|
| 55 |
+
self._llm = OpenAI(base_url=api_base, api_key=api_key)
|
| 56 |
+
else:
|
| 57 |
+
self._llm = None
|
| 58 |
+
|
| 59 |
+
def _load_task_bank(self):
|
| 60 |
+
"""Load the bank of prompt optimization tasks."""
|
| 61 |
+
return [
|
| 62 |
+
# EASY TASK
|
| 63 |
+
{
|
| 64 |
+
"difficulty": "easy",
|
| 65 |
+
"prompt": "Can you please explain in a very detailed manner what machine learning is and how it works step by step?",
|
| 66 |
+
"expected_format": "brief explanation",
|
| 67 |
+
"reference_response": "Machine learning is a subset of AI that enables computers to learn from data without explicit programming. It works by identifying patterns in training data to make predictions or decisions on new data.",
|
| 68 |
+
"max_output_tokens": 50,
|
| 69 |
+
"description": "Reduce verbosity while preserving core concept"
|
| 70 |
+
},
|
| 71 |
+
# MEDIUM TASK
|
| 72 |
+
{
|
| 73 |
+
"difficulty": "medium",
|
| 74 |
+
"prompt": "I need a comprehensive analysis of the renewable energy market trends over the past decade, including solar, wind, and hydroelectric power growth rates, investment patterns, technological advancements, and policy impacts across different regions globally.",
|
| 75 |
+
"expected_format": "5 bullet points summarizing key trends",
|
| 76 |
+
"reference_response": "• Solar power capacity grew 22% annually avg. • Wind energy investments reached $140B in 2020 • Hydroelectric remains largest renewable source • Battery storage tech advancing rapidly • Policy incentives driving global adoption",
|
| 77 |
+
"max_output_tokens": 100,
|
| 78 |
+
"description": "Compress input + specify bullet point format + length limit"
|
| 79 |
+
},
|
| 80 |
+
# HARD TASK
|
| 81 |
+
{
|
| 82 |
+
"difficulty": "hard",
|
| 83 |
+
"prompt": "As a senior data scientist, I need you to analyze our Q3 sales performance dataset and provide actionable insights. The dataset contains: customer demographics, purchase history, product categories, regional sales data, marketing campaign ROI, seasonal trends, and competitor analysis. Please identify: 1) Our top 3 performing product categories and why, 2) Geographic regions with highest growth potential, 3) Customer segments most responsive to our email campaigns, 4) Optimal marketing budget allocation for Q4, and 5) Risks to watch based on economic indicators.",
|
| 84 |
+
"expected_format": "JSON with 5 keys: top_categories, growth_regions, responsive_segments, budget_allocation, risks_watch",
|
| 85 |
+
"reference_response": '{"top_categories": ["electronics", "software", "home_goods"], "growth_regions": ["SE Asia", "Latin America", "Africa"], "responsive_segments": ["young_professionals", "tech_enthusiasts"], "budget_allocation": {"email": 0.3, "social": 0.25, "search": 0.2, "tv": 0.15, "other": 0.1}, "risks_watch": ["inflation", "supply_chain", "labor_shortage"]}',
|
| 86 |
+
"max_output_tokens": 200,
|
| 87 |
+
"description": "Multi-intent optimization: compress complex request + specify JSON format + accuracy + length constraints"
|
| 88 |
+
}
|
| 89 |
+
]
|
| 90 |
+
|
| 91 |
+
def reset(self) -> TokenOptimiserObservation:
|
| 92 |
+
"""
|
| 93 |
+
Reset the environment with a random task from the task bank.
|
| 94 |
+
|
| 95 |
+
Returns:
|
| 96 |
+
TokenOptimiserObservation with initial state
|
| 97 |
+
"""
|
| 98 |
+
# Select a random task
|
| 99 |
+
self._current_task = random.choice(self._task_bank)
|
| 100 |
+
self._state = TokenOptimiserState(
|
| 101 |
+
episode_id=str(uuid4()),
|
| 102 |
+
step_count=0,
|
| 103 |
+
original_prompt=self._current_task["prompt"],
|
| 104 |
+
task_difficulty=self._current_task["difficulty"],
|
| 105 |
+
task_index=self._task_bank.index(self._current_task)
|
| 106 |
+
)
|
| 107 |
+
self._reset_count += 1
|
| 108 |
+
|
| 109 |
+
return TokenOptimiserObservation(
|
| 110 |
+
llm_response="",
|
| 111 |
+
input_tokens=0,
|
| 112 |
+
output_tokens=0,
|
| 113 |
+
reward=0.0
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
def step(self, action: TokenOptimiserAction) -> TokenOptimiserObservation: # type: ignore[override]
|
| 117 |
+
"""
|
| 118 |
+
Execute a step in the environment by evaluating the agent's optimized prompt.
|
| 119 |
+
|
| 120 |
+
Args:
|
| 121 |
+
action: TokenOptimiserAction containing the optimized prompt
|
| 122 |
+
|
| 123 |
+
Returns:
|
| 124 |
+
TokenOptimiserObservation with LLM response simulation and reward
|
| 125 |
+
"""
|
| 126 |
+
self._state.step_count += 1
|
| 127 |
+
optimized_prompt = action.optimized_prompt
|
| 128 |
+
original_prompt = self._current_task["prompt"]
|
| 129 |
+
|
| 130 |
+
# 1. Call real LLM (or fallback) to get the actual response + token counts
|
| 131 |
+
llm_response, input_tokens, output_tokens = self._call_llm(optimized_prompt)
|
| 132 |
+
|
| 133 |
+
# 2. LLM-as-judge: semantic quality score (0.0-1.0)
|
| 134 |
+
semantic_score = self._judge_semantic_quality(original_prompt, llm_response)
|
| 135 |
+
|
| 136 |
+
# 3. Token efficiency: how much did we reduce vs the original prompt token count?
|
| 137 |
+
original_tokens = len(original_prompt.split()) * 1.3
|
| 138 |
+
ref_output_tokens = len(self._current_task["reference_response"].split()) * 1.3
|
| 139 |
+
ref_total = original_tokens + ref_output_tokens
|
| 140 |
+
actual_total = input_tokens + output_tokens
|
| 141 |
+
token_efficiency = max(0.0, min(0.4, (ref_total - actual_total) / max(ref_total, 1)))
|
| 142 |
+
|
| 143 |
+
# 4. Format compliance (0.0-0.2)
|
| 144 |
+
expected_fmt = self._current_task["expected_format"]
|
| 145 |
+
format_score = 0.0
|
| 146 |
+
if "bullet" in expected_fmt and any(c in llm_response for c in ("•", "*", "-", "\n")):
|
| 147 |
+
format_score = 0.2
|
| 148 |
+
elif "json" in expected_fmt.lower() and "{" in llm_response and "}" in llm_response:
|
| 149 |
+
format_score = 0.2
|
| 150 |
+
elif "brief" in expected_fmt and len(llm_response.split()) < 30:
|
| 151 |
+
format_score = 0.2
|
| 152 |
+
|
| 153 |
+
# 5. Length penalty if output way too long
|
| 154 |
+
max_out = self._current_task["max_output_tokens"]
|
| 155 |
+
length_penalty = -0.1 if output_tokens > max_out * 2 else 0.0
|
| 156 |
+
|
| 157 |
+
# Final reward: weighted hybrid
|
| 158 |
+
reward = (
|
| 159 |
+
token_efficiency # 0.0 - 0.4 (token saving)
|
| 160 |
+
+ semantic_score * 0.3 # 0.0 - 0.3 (LLM judge quality)
|
| 161 |
+
+ format_score # 0.0 - 0.2 (format compliance)
|
| 162 |
+
+ length_penalty # 0.0 or -0.1 (penalty)
|
| 163 |
+
)
|
| 164 |
+
reward = max(0.0, min(1.0, reward))
|
| 165 |
+
|
| 166 |
+
print(
|
| 167 |
+
f"[ENV] tok_eff={token_efficiency:.2f} semantic={semantic_score:.2f} "
|
| 168 |
+
f"fmt={format_score:.2f} => reward={reward:.2f}",
|
| 169 |
+
flush=True,
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
return TokenOptimiserObservation(
|
| 173 |
+
llm_response=llm_response,
|
| 174 |
+
input_tokens=int(input_tokens),
|
| 175 |
+
output_tokens=int(output_tokens),
|
| 176 |
+
reward=reward
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
def _call_llm(self, prompt: str) -> tuple[str, int, int]:
|
| 180 |
+
"""
|
| 181 |
+
Call the real LLM with the optimized prompt.
|
| 182 |
+
Returns (response_text, input_tokens, output_tokens).
|
| 183 |
+
Falls back to rule-based simulation if LLM is unavailable.
|
| 184 |
+
"""
|
| 185 |
+
if self._llm is not None:
|
| 186 |
+
try:
|
| 187 |
+
resp = self._llm.chat.completions.create(
|
| 188 |
+
model=self._model,
|
| 189 |
+
messages=[{"role": "user", "content": prompt}],
|
| 190 |
+
max_tokens=200,
|
| 191 |
+
temperature=0.3,
|
| 192 |
+
)
|
| 193 |
+
text = (resp.choices[0].message.content or "").strip()
|
| 194 |
+
in_tok = resp.usage.prompt_tokens if resp.usage else len(prompt.split())
|
| 195 |
+
out_tok = resp.usage.completion_tokens if resp.usage else len(text.split())
|
| 196 |
+
return text, in_tok, out_tok
|
| 197 |
+
except Exception as e:
|
| 198 |
+
print(f"[ENV] LLM call failed, using fallback: {e}")
|
| 199 |
+
|
| 200 |
+
# Rule-based fallback
|
| 201 |
+
return self._fallback_simulate(prompt)
|
| 202 |
+
|
| 203 |
+
def _fallback_simulate(self, prompt: str) -> tuple[str, int, int]:
|
| 204 |
+
"""Fast deterministic fallback when LLM is unavailable."""
|
| 205 |
+
if self._current_task is None:
|
| 206 |
+
text = "No task loaded."
|
| 207 |
+
return text, len(prompt.split()), len(text.split())
|
| 208 |
+
|
| 209 |
+
expected_format = self._current_task["expected_format"]
|
| 210 |
+
original_words = len(self._current_task["prompt"].split())
|
| 211 |
+
compression_ratio = len(prompt.split()) / max(original_words, 1)
|
| 212 |
+
|
| 213 |
+
if "brief explanation" in expected_format:
|
| 214 |
+
text = ("Machine learning is AI that learns from data to make predictions."
|
| 215 |
+
if compression_ratio <= 0.6
|
| 216 |
+
else "Machine learning enables systems to learn from experience and improve without explicit programming.")
|
| 217 |
+
elif "bullet" in expected_format:
|
| 218 |
+
text = ("• Solar power growing rapidly\n• Wind energy investments increasing\n• Hydroelectric power stable\n• Battery storage advancing\n• Global renewable adoption rising"
|
| 219 |
+
if compression_ratio <= 0.7
|
| 220 |
+
else "Renewable energy sectors are growing, led by solar and wind with strong policy support.")
|
| 221 |
+
elif "json" in expected_format.lower():
|
| 222 |
+
text = ('{"top_categories": ["electronics", "software"], "growth_regions": ["Asia", "Africa"], "responsive_segments": ["professionals"], "budget_allocation": {"email": 0.4, "social": 0.3}, "risks_watch": ["inflation"]}' # noqa
|
| 223 |
+
if compression_ratio <= 0.6
|
| 224 |
+
else "Key categories: electronics, software. Growth in Asia and Africa.")
|
| 225 |
+
else:
|
| 226 |
+
text = "I understand your request and will provide a helpful response."
|
| 227 |
+
|
| 228 |
+
in_tok = int(len(prompt.split()) * 1.3)
|
| 229 |
+
out_tok = int(len(text.split()) * 1.3)
|
| 230 |
+
return text, in_tok, out_tok
|
| 231 |
+
|
| 232 |
+
def _judge_semantic_quality(self, original_prompt: str, response: str) -> float:
|
| 233 |
+
"""
|
| 234 |
+
LLM-as-judge: score how well the response answers the original prompt.
|
| 235 |
+
Returns a float 0.0-1.0.
|
| 236 |
+
"""
|
| 237 |
+
if self._llm is None:
|
| 238 |
+
return self._keyword_fallback_score(original_prompt, response)
|
| 239 |
+
|
| 240 |
+
judge_prompt = (
|
| 241 |
+
f"Rate 0 to 10 how well the RESPONSE answers the ORIGINAL question. "
|
| 242 |
+
f"Consider accuracy and completeness. Reply with a single integer only.\n\n"
|
| 243 |
+
f"ORIGINAL: {original_prompt[:300]}\n\nRESPONSE: {response[:400]}"
|
| 244 |
+
)
|
| 245 |
+
try:
|
| 246 |
+
resp = self._llm.chat.completions.create(
|
| 247 |
+
model=self._model,
|
| 248 |
+
messages=[{"role": "user", "content": judge_prompt}],
|
| 249 |
+
max_tokens=5,
|
| 250 |
+
temperature=0.0,
|
| 251 |
+
)
|
| 252 |
+
raw = (resp.choices[0].message.content or "5").strip()
|
| 253 |
+
score = int("".join(c for c in raw if c.isdigit())[:2] or "5")
|
| 254 |
+
return min(max(score / 10.0, 0.0), 1.0)
|
| 255 |
+
except Exception:
|
| 256 |
+
return self._keyword_fallback_score(original_prompt, response)
|
| 257 |
+
|
| 258 |
+
def _keyword_fallback_score(self, original_prompt: str, response: str) -> float:
|
| 259 |
+
"""Simple keyword overlap as semantic score when judge is unavailable."""
|
| 260 |
+
key_concepts = {
|
| 261 |
+
"machine", "learning", "ai", "data", "predict", "solar", "wind",
|
| 262 |
+
"energy", "renewable", "sales", "customer", "product", "market",
|
| 263 |
+
"budget", "analysis", "trend", "growth", "json", "bullet",
|
| 264 |
+
}
|
| 265 |
+
orig = set(original_prompt.lower().split()) & key_concepts
|
| 266 |
+
resp = set(response.lower().split()) & key_concepts
|
| 267 |
+
raw = (len(resp) / len(orig)) if orig else 0.5
|
| 268 |
+
return min(raw, 1.0)
|
| 269 |
+
|
| 270 |
+
def _calculate_reward(self, original_prompt: str, optimized_prompt: str,
|
| 271 |
+
llm_response: str, expected_format: str, reference_response: str,
|
| 272 |
+
input_tokens: int, output_tokens: int) -> float:
|
| 273 |
+
"""
|
| 274 |
+
Calculate multi-component reward score (0.0-1.0).
|
| 275 |
+
"""
|
| 276 |
+
# Component 1: Token Efficiency (0.0-0.4)
|
| 277 |
+
original_tokens = len(original_prompt.split()) * 1.3
|
| 278 |
+
optimized_input_tokens = input_tokens
|
| 279 |
+
output_tokens_estimate = output_tokens
|
| 280 |
+
|
| 281 |
+
# Reference token counts for comparison
|
| 282 |
+
ref_input_tokens = len(self._current_task["prompt"].split()) * 1.3
|
| 283 |
+
ref_output_tokens = len(self._current_task["reference_response"].split()) * 1.3
|
| 284 |
+
ref_total_tokens = ref_input_tokens + ref_output_tokens
|
| 285 |
+
|
| 286 |
+
actual_total_tokens = optimized_input_tokens + output_tokens_estimate
|
| 287 |
+
token_efficiency = max(0, (ref_total_tokens - actual_total_tokens) / ref_total_tokens)
|
| 288 |
+
token_efficiency = min(token_efficiency, 0.4) # Cap at 0.4
|
| 289 |
+
|
| 290 |
+
# Component 2: Semantic Preservation (0.0-0.3)
|
| 291 |
+
# Simple keyword-based similarity (in practice, would use embeddings)
|
| 292 |
+
original_keywords = set(original_prompt.lower().split())
|
| 293 |
+
response_keywords = set(llm_response.lower().split())
|
| 294 |
+
|
| 295 |
+
# Extract key concepts from original prompt
|
| 296 |
+
key_concepts = {"machine", "learning", "AI", "data", "predict", "solar", "wind",
|
| 297 |
+
"energy", "renewable", "sales", "customer", "product", "market",
|
| 298 |
+
"budget", "analysis", "trend", "growth", "json", "bullet", "point"}
|
| 299 |
+
|
| 300 |
+
original_key_concepts = original_keywords & key_concepts
|
| 301 |
+
response_key_concepts = response_keywords & key_concepts
|
| 302 |
+
|
| 303 |
+
if len(original_key_concepts) > 0:
|
| 304 |
+
semantic_similarity = len(response_key_concepts) / len(original_key_concepts)
|
| 305 |
+
else:
|
| 306 |
+
semantic_similarity = 0.5 # Neutral if no key concepts found
|
| 307 |
+
|
| 308 |
+
semantic_score = min(semantic_similarity, 0.3) # Cap at 0.3
|
| 309 |
+
|
| 310 |
+
# Component 3: Format Compliance (0.0-0.2)
|
| 311 |
+
format_score = 0.0
|
| 312 |
+
if "bullet point" in expected_format.lower() and ("•" in llm_response or "*" in llm_response or "-" in llm_response):
|
| 313 |
+
format_score = 0.2
|
| 314 |
+
elif "json" in expected_format.lower() and ("{" in llm_response and "}" in llm_response):
|
| 315 |
+
format_score = 0.2
|
| 316 |
+
elif "brief explanation" in expected_format.lower() and len(llm_response.split()) < 30:
|
| 317 |
+
format_score = 0.2
|
| 318 |
+
|
| 319 |
+
# Component 4: Length Appropriateness (0.0-0.1)
|
| 320 |
+
length_score = 0.0
|
| 321 |
+
max_expected = self._current_task["max_output_tokens"]
|
| 322 |
+
if output_tokens <= max_expected:
|
| 323 |
+
length_score = 0.1
|
| 324 |
+
elif output_tokens <= max_expected * 1.5: # Partial credit
|
| 325 |
+
length_score = 0.05
|
| 326 |
+
|
| 327 |
+
# Component 5: Cost Simulation Bonus (0.0-0.05)
|
| 328 |
+
# Reward for being under reference token count
|
| 329 |
+
cost_bonus = 0.0
|
| 330 |
+
if actual_total_tokens < ref_total_tokens:
|
| 331 |
+
cost_bonus = min(0.05, (ref_total_tokens - actual_total_tokens) / ref_total_tokens * 0.05)
|
| 332 |
+
|
| 333 |
+
# Component 6: Latency Penalty (penalty)
|
| 334 |
+
latency_penalty = 0.0
|
| 335 |
+
if output_tokens > max_expected * 2:
|
| 336 |
+
latency_penalty = -0.1
|
| 337 |
+
|
| 338 |
+
# Component 7: Context Window Penalty (penalty)
|
| 339 |
+
context_penalty = 0.0
|
| 340 |
+
# Simulate context window limit (e.g., 4096 tokens)
|
| 341 |
+
if input_tokens > 3000: # Assuming prompt + context
|
| 342 |
+
context_penalty = -0.1
|
| 343 |
+
|
| 344 |
+
# Calculate final reward
|
| 345 |
+
total_reward = (
|
| 346 |
+
token_efficiency +
|
| 347 |
+
semantic_score +
|
| 348 |
+
format_score +
|
| 349 |
+
length_score +
|
| 350 |
+
cost_bonus +
|
| 351 |
+
latency_penalty +
|
| 352 |
+
context_penalty
|
| 353 |
+
)
|
| 354 |
+
|
| 355 |
+
# Clamp to valid range
|
| 356 |
+
return max(0.0, min(1.0, total_reward))
|
| 357 |
+
|
| 358 |
+
@property
|
| 359 |
+
def state(self) -> TokenOptimiserState:
|
| 360 |
+
"""
|
| 361 |
+
Get the current environment state.
|
| 362 |
+
|
| 363 |
+
Returns:
|
| 364 |
+
Current TokenOptimiserState
|
| 365 |
+
"""
|
| 366 |
+
return self._state
|
uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|