Skip to content

Commit 8b3c69f

Browse files
committed
chore: rename package, imports, and address security review
1 parent 937542a commit 8b3c69f

13 files changed

Lines changed: 217 additions & 67 deletions

File tree

packages/optimization/Makefile

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@ test: install
1919
.PHONY: lint
2020
lint: #! Run type analysis and linting checks
2121
lint: install
22-
uv run mypy src/ldai_optimization
23-
uv run isort --check --atomic src/ldai_optimization
24-
uv run pycodestyle src/ldai_optimization
22+
uv run mypy src/ldai_optimizer
23+
uv run isort --check --atomic src/ldai_optimizer
24+
uv run pycodestyle src/ldai_optimizer
2525

2626
.PHONY: build
2727
build: #! Build distribution files

packages/optimization/README.md

Lines changed: 108 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,127 @@
11
# LaunchDarkly AI SDK — optimization
22

3-
[![PyPI](https://img.shields.io/pypi/v/launchdarkly-server-sdk-ai-optimization.svg?style=flat-square)](https://pypi.org/project/launchdarkly-server-sdk-ai-optimization/)
3+
[![PyPI](https://img.shields.io/pypi/v/ldai_optimizer.svg?style=flat-square)](https://pypi.org/project/ldai_optimizer/)
44

55
> [!CAUTION]
66
> This package is in pre-release and not subject to backwards compatibility
77
> guarantees. The API may change based on feedback.
88
>
99
> Pin to a specific minor version and review the [changelog](CHANGELOG.md) before upgrading.
1010
11-
This package will provide helpers to run selected tools against the [LaunchDarkly API](https://apidocs.launchdarkly.com/) from SDK-based workflows. The public surface is not yet finalized; see [CHANGELOG.md](CHANGELOG.md) for updates.
11+
This package provides helpers for running iterative AI prompt optimization workflows from within LaunchDarkly SDK-based applications. It drives the optimization loop — generating candidate variations, evaluating them with judges, and optionally committing winners back to LaunchDarkly — while delegating all LLM calls to your own handler functions.
12+
13+
## Requirements
14+
15+
- Python `>=3.9`
16+
- A configured [LaunchDarkly server-side SDK](https://docs.launchdarkly.com/sdk/server-side/python) client
17+
- The [LaunchDarkly AI package](https://pypi.org/project/launchdarkly-server-sdk-ai/) (`launchdarkly-server-sdk-ai>=0.16.0`) — pulled in automatically as a dependency
18+
- **`LAUNCHDARKLY_API_KEY` environment variable** — required only when using `auto_commit=True` or `optimize_from_config`. Not needed for basic `optimize_from_options` runs without auto-commit.
19+
20+
> [!NOTE]
21+
> **`LAUNCHDARKLY_API_KEY` is used exclusively for discrete LaunchDarkly REST API calls** (fetching configs, publishing results). It is never included in any LLM prompt and is never forwarded to your handler callbacks. All API calls made by this package are isolated; they have no access to your runtime environment beyond the key you explicitly provide via the environment variable.
1222
1323
## Installation
1424

1525
```bash
16-
pip install launchdarkly-server-sdk-ai-optimization
26+
pip install ldai_optimizer
27+
```
28+
29+
## Quick Start
30+
31+
### Basic optimization (`optimize_from_options`)
32+
33+
No `LAUNCHDARKLY_API_KEY` required unless `auto_commit=True`.
34+
35+
```python
36+
import ldclient
37+
from ldai import LDAIClient
38+
from ldai_optimizer import (
39+
OptimizationClient,
40+
OptimizationJudge,
41+
OptimizationOptions,
42+
OptimizationResponse,
43+
LLMCallConfig,
44+
LLMCallContext,
45+
)
46+
47+
ldclient.set_config(ldclient.Config("sdk-your-sdk-key"))
48+
ld = LDAIClient(ldclient.get())
49+
client = OptimizationClient(ld)
50+
51+
def handle_llm_call(
52+
run_id: str,
53+
config: LLMCallConfig,
54+
context: LLMCallContext,
55+
is_evaluation: bool,
56+
) -> OptimizationResponse:
57+
# config.model, config.instructions, config.key are available
58+
# context.user_input, context.current_variables are available
59+
response = your_llm_client.chat(
60+
model=config.model.name if config.model else "gpt-4o",
61+
system=config.instructions,
62+
user=context.user_input or "",
63+
)
64+
return OptimizationResponse(completion=response.text)
65+
66+
result = await client.optimize_from_options(
67+
OptimizationOptions(
68+
agent_key="my-agent",
69+
handle_agent_call=handle_llm_call,
70+
judge_model="gpt-4o-mini",
71+
judges={
72+
"quality": OptimizationJudge(
73+
acceptance_statement="The response is accurate and concise."
74+
)
75+
},
76+
model_choices=["gpt-4o", "gpt-4o-mini"],
77+
variable_choices=[{"user_id": "user-123"}],
78+
user_input_choices=["What is my account balance?"],
79+
)
80+
)
1781
```
1882

19-
## Status
83+
### Ground truth optimization
84+
85+
```python
86+
from ldai_optimizer import GroundTruthOptimizationOptions, GroundTruthSample
87+
88+
result = await client.optimize_from_options(
89+
GroundTruthOptimizationOptions(
90+
agent_key="my-agent",
91+
handle_agent_call=handle_llm_call,
92+
judge_model="gpt-4o-mini",
93+
judges={
94+
"accuracy": OptimizationJudge(
95+
acceptance_statement="The response matches the expected answer."
96+
)
97+
},
98+
model_choices=["gpt-4o", "gpt-4o-mini"],
99+
ground_truth_responses=[
100+
GroundTruthSample(
101+
user_input="What is 2+2?",
102+
ground_truth_response="4",
103+
)
104+
],
105+
)
106+
)
107+
```
20108

21-
- 3/24/26: Initial package creation
109+
### Config-driven optimization (`optimize_from_config`)
110+
111+
Requires `LAUNCHDARKLY_API_KEY`.
112+
113+
```python
114+
from ldai_optimizer import OptimizationFromConfigOptions
115+
116+
result = await client.optimize_from_config(
117+
OptimizationFromConfigOptions(
118+
config_key="my-optimization-config",
119+
project_key="my-project",
120+
handle_agent_call=handle_llm_call,
121+
auto_commit=True,
122+
)
123+
)
124+
```
22125

23126
## License
24127

packages/optimization/pyproject.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[project]
2-
name = "launchdarkly-server-sdk-ai-optimization"
2+
name = "ldai_optimizer"
33
version = "0.0.0" # x-release-please-version
4-
description = "LaunchDarkly AI SDK optimization helpers"
4+
description = "LaunchDarkly AI tool — optimizer"
55
authors = [{name = "LaunchDarkly", email = "dev@launchdarkly.com"}]
66
license = {text = "Apache-2.0"}
77
readme = "README.md"
@@ -43,7 +43,7 @@ requires = ["hatchling"]
4343
build-backend = "hatchling.build"
4444

4545
[tool.hatch.build.targets.wheel]
46-
packages = ["src/ldai_optimization"]
46+
packages = ["src/ldai_optimizer"]
4747

4848
[tool.mypy]
4949
python_version = "3.9"

packages/optimization/src/ldai_optimization/__init__.py renamed to packages/optimization/src/ldai_optimizer/__init__.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55

66
from ldai.tracker import TokenUsage
77

8-
from ldai_optimization.client import OptimizationClient
9-
from ldai_optimization.dataclasses import (
8+
from ldai_optimizer.client import OptimizationClient
9+
from ldai_optimizer.dataclasses import (
1010
AIJudgeCallConfig,
1111
GroundTruthOptimizationOptions,
1212
GroundTruthSample,
@@ -20,7 +20,7 @@
2020
OptimizationResponse,
2121
ToolDefinition,
2222
)
23-
from ldai_optimization.ld_api_client import LDApiError
23+
from ldai_optimizer.ld_api_client import LDApiError
2424

2525
__version__ = "0.0.0"
2626

packages/optimization/src/ldai_optimization/client.py renamed to packages/optimization/src/ldai_optimizer/client.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,19 @@
1-
"""Client for LaunchDarkly AI agent optimization."""
1+
"""Client for LaunchDarkly AI agent optimization.
2+
3+
Security note — LAUNCHDARKLY_API_KEY scope
4+
-------------------------------------------
5+
When set, the ``LAUNCHDARKLY_API_KEY`` environment variable is used solely to
6+
authenticate discrete LaunchDarkly REST API calls (e.g. fetching optimization
7+
configs, publishing results via ``auto_commit``). It is:
8+
9+
- Never included in any LLM prompt.
10+
- Never forwarded to user-supplied ``handle_agent_call`` or ``handle_judge_call``
11+
callbacks.
12+
- Never accessible to any external service other than the LaunchDarkly REST API.
13+
14+
All LaunchDarkly API calls are isolated requests; they carry no information
15+
about the caller's broader runtime environment beyond the key itself.
16+
"""
217

318
import dataclasses
419
import json
@@ -14,7 +29,7 @@
1429
from ldai.models import LDMessage, ModelConfig
1530
from ldclient import Context
1631

17-
from ldai_optimization.dataclasses import (
32+
from ldai_optimizer.dataclasses import (
1833
AIJudgeCallConfig,
1934
GroundTruthOptimizationOptions,
2035
GroundTruthSample,
@@ -28,26 +43,28 @@
2843
OptimizationResponse,
2944
ToolDefinition,
3045
)
31-
from ldai_optimization.ld_api_client import (
46+
from ldai_optimizer.ld_api_client import (
3247
AgentOptimizationConfig,
3348
AgentOptimizationResultPatch,
3449
AgentOptimizationResultPost,
3550
LDApiClient,
3651
)
37-
from ldai_optimization.prompts import (
52+
from ldai_optimizer.prompts import (
3853
_acceptance_criteria_implies_duration_optimization,
3954
build_message_history_text,
4055
build_new_variation_prompt,
4156
build_reasoning_history,
4257
)
43-
from ldai_optimization.util import (
58+
from ldai_optimizer.util import (
59+
RedactionFilter,
4460
await_if_needed,
4561
extract_json_from_response,
4662
interpolate_variables,
4763
restore_variable_placeholders,
4864
)
4965

5066
logger = logging.getLogger(__name__)
67+
logger.addFilter(RedactionFilter())
5168

5269

5370
def _find_model_config(

packages/optimization/src/ldai_optimization/dataclasses.py renamed to packages/optimization/src/ldai_optimizer/dataclasses.py

File renamed without changes.

packages/optimization/src/ldai_optimization/ld_api_client.py renamed to packages/optimization/src/ldai_optimizer/ld_api_client.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66
import urllib.request
77
from typing import Any, Dict, List, Optional, TypedDict
88

9+
from ldai_optimizer.util import RedactionFilter
10+
911
logger = logging.getLogger(__name__)
12+
logger.addFilter(RedactionFilter())
1013

1114
_BASE_URL = "https://app.launchdarkly.com"
1215

packages/optimization/src/ldai_optimization/prompts.py renamed to packages/optimization/src/ldai_optimizer/prompts.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import re
44
from typing import Any, Dict, List, Optional
55

6-
from ldai_optimization.dataclasses import (
6+
from ldai_optimizer.dataclasses import (
77
OptimizationContext,
88
OptimizationJudge,
99
)
@@ -62,12 +62,12 @@ def build_message_history_text(
6262
turn_messages = []
6363
for ctx in history:
6464
if ctx.user_input:
65-
turn_messages.append(f"User: {ctx.user_input}")
65+
turn_messages.append(f"User: <untrusted>{ctx.user_input}</untrusted>")
6666
if ctx.completion_response:
67-
turn_messages.append(f"Assistant: {ctx.completion_response}")
67+
turn_messages.append(f"Assistant: <untrusted>{ctx.completion_response}</untrusted>")
6868

6969
# Include the current turn's question so judges see what was actually asked
70-
turn_messages.append(f"User: {current_user_input}")
70+
turn_messages.append(f"User: <untrusted>{current_user_input}</untrusted>")
7171

7272
parts = []
7373
if input_text:
@@ -243,8 +243,8 @@ def variation_prompt_configuration(
243243
"## Most Recent Result:",
244244
]
245245
if previous_ctx.user_input:
246-
lines.append(f"User question: {previous_ctx.user_input}")
247-
lines.append(f"Agent response: {previous_ctx.completion_response}")
246+
lines.append(f"User question: <untrusted>{previous_ctx.user_input}</untrusted>")
247+
lines.append(f"Agent response: <untrusted>{previous_ctx.completion_response}</untrusted>")
248248
if previous_ctx.duration_ms is not None:
249249
lines.append(f"Agent duration: {previous_ctx.duration_ms:.0f}ms")
250250
return "\n".join(lines)
@@ -279,7 +279,7 @@ def variation_prompt_feedback(
279279
for ctx in iterations_with_scores:
280280
lines.append(f"\n### Iteration {ctx.iteration}:")
281281
if ctx.user_input:
282-
lines.append(f"User question: {ctx.user_input}")
282+
lines.append(f"User question: <untrusted>{ctx.user_input}</untrusted>")
283283
for judge_key, result in ctx.scores.items():
284284
optimization_judge = judges.get(judge_key) if judges else None
285285
if optimization_judge:
@@ -332,11 +332,11 @@ def variation_prompt_overfit_warning(history: List[OptimizationContext]) -> str:
332332
]
333333

334334
if recent.user_input:
335-
lines.append(f'- User input: "{recent.user_input}"')
335+
lines.append(f'- User input: <untrusted>"{recent.user_input}"</untrusted>')
336336

337337
if recent.current_variables:
338338
for k, v in recent.current_variables.items():
339-
lines.append(f' - placeholder {{{{{k}}}}}, current value: "{v}"')
339+
lines.append(f' - placeholder {{{{{k}}}}}, current value: <untrusted>"{v}"</untrusted>')
340340
lines.append(
341341
" (These are the placeholder NAMES mapped to their current VALUES"
342342
" — never use a value as a placeholder name)"
@@ -393,7 +393,7 @@ def variation_prompt_improvement_instructions(
393393
"",
394394
]
395395
for k in sorted(examples.keys()):
396-
vals = ", ".join(f'"{v}"' for v in examples[k])
396+
vals = ", ".join(f'"<untrusted>{v}</untrusted>"' for v in examples[k])
397397
table_lines.append(f" - {{{{{k}}}}} (example values: {vals})")
398398

399399
# Build concrete bad/good counterexamples using the actual keys and values
@@ -405,8 +405,8 @@ def variation_prompt_improvement_instructions(
405405
"IMPORTANT: The names above are the KEYS — they are the placeholder names.",
406406
"The values listed are only runtime examples that will be substituted at call time.",
407407
"NEVER use a runtime value as a placeholder name.",
408-
f'BAD: "...{{{{...{first_val}...}}}}..." '
409-
f'— "{first_val}" is a runtime value, not a placeholder name',
408+
f'BAD: "...{{{{...<untrusted>{first_val}</untrusted>...}}}}..." '
409+
f'— "<untrusted>{first_val}</untrusted>" is a runtime value, not a placeholder name',
410410
f'GOOD: "...{{{{{first_key}}}}}..." '
411411
f'— "{first_key}" is the correct placeholder name',
412412
]

packages/optimization/src/ldai_optimization/util.py renamed to packages/optimization/src/ldai_optimizer/util.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,37 @@
66
import re
77
from typing import Any, Awaitable, Dict, List, Optional, Tuple, TypeVar, Union
88

9-
from ldai_optimization.dataclasses import ToolDefinition
9+
from ldai_optimizer.dataclasses import ToolDefinition
1010

1111
logger = logging.getLogger(__name__)
1212

13+
# Matches LaunchDarkly API key and SDK key formats:
14+
# api-<hex/alphanumeric, 16+ chars>
15+
# sdk-<hex/alphanumeric, 16+ chars>
16+
# cli-<hex/alphanumeric, 16+ chars>
17+
_KEY_PATTERN = re.compile(r"\b(api|sdk|cli)-[A-Za-z0-9_\-]{16,}\b")
18+
19+
20+
class RedactionFilter(logging.Filter):
21+
"""Logging filter that redacts strings resembling LaunchDarkly API keys.
22+
23+
Scrubs both the format string (``record.msg``) and each positional argument
24+
(``record.args``) before the handler formats the final log line, so raw key
25+
values are never written to any log destination.
26+
"""
27+
28+
def filter(self, record: logging.LogRecord) -> bool:
29+
record.msg = _KEY_PATTERN.sub("[REDACTED]", str(record.msg))
30+
if record.args:
31+
record.args = tuple(
32+
_KEY_PATTERN.sub("[REDACTED]", str(a)) if isinstance(a, str) else a
33+
for a in (record.args if isinstance(record.args, tuple) else (record.args,))
34+
)
35+
return True
36+
37+
38+
logger.addFilter(RedactionFilter())
39+
1340

1441
def handle_evaluation_tool_call(score: float, rationale: str) -> str:
1542
"""

0 commit comments

Comments
 (0)