Skip to content

Commit d3e1f96

Browse files
committed
feat: partially implement optimize_from_config
1 parent 2fecd54 commit d3e1f96

5 files changed

Lines changed: 126 additions & 23 deletions

File tree

packages/optimization/src/ldai_optimization/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@
1313
OptimizationOptions,
1414
ToolDefinition,
1515
)
16+
from ldai_optimization.ld_api_client import LDApiError
1617

1718
__version__ = "0.0.0"
1819

1920
__all__ = [
2021
'__version__',
2122
'AIJudgeCallConfig',
23+
'LDApiError',
2224
'OptimizationClient',
2325
'OptimizationContext',
2426
'OptimizationFromConfigOptions',

packages/optimization/src/ldai_optimization/client.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,21 @@
4545

4646
logger = logging.getLogger(__name__)
4747

48+
49+
def _strip_provider_prefix(model: str) -> str:
50+
"""Strip the provider prefix from a model identifier returned by the LD API.
51+
52+
API model keys are formatted as "Provider.model-name" (e.g. "OpenAI.gpt-5",
53+
"Anthropic.claude-opus-4.6"). Only the part after the first period is needed
54+
by the underlying LLM clients. If no period is present the string is returned
55+
unchanged.
56+
57+
:param model: Raw model string from the API.
58+
:return: Model name with provider prefix removed.
59+
"""
60+
return model.split(".", 1)[-1]
61+
62+
4863
# Maps SDK status strings to the API status/activity values expected by
4964
# agent_optimization_result records. Defined at module level to avoid
5065
# allocating the dict on every on_status_update invocation.
@@ -981,10 +996,12 @@ def _build_options_from_config(
981996
judge_key=judge["key"],
982997
)
983998

984-
if not judges and options.on_turn is None:
999+
has_ground_truth = bool(config.get("groundTruthResponses"))
1000+
if not judges and not has_ground_truth and options.on_turn is None:
9851001
raise ValueError(
986-
"The optimization config has no acceptance statements or judges, "
987-
"and no on_turn callback was provided. At least one is required."
1002+
"The optimization config has no acceptance statements, judges, or ground truth "
1003+
"responses, and no on_turn callback was provided. At least one is required to "
1004+
"evaluate optimization results."
9881005
)
9891006

9901007
variable_choices: List[Dict[str, Any]] = config["variableChoices"] or [{}]
@@ -1034,8 +1051,8 @@ def _persist_and_forward(
10341051
return OptimizationOptions(
10351052
context_choices=options.context_choices,
10361053
max_attempts=config["maxAttempts"],
1037-
model_choices=config["modelChoices"],
1038-
judge_model=config["judgeModel"],
1054+
model_choices=[_strip_provider_prefix(m) for m in config["modelChoices"]],
1055+
judge_model=_strip_provider_prefix(config["judgeModel"]),
10391056
variable_choices=variable_choices,
10401057
handle_agent_call=options.handle_agent_call,
10411058
handle_judge_call=options.handle_judge_call,

packages/optimization/src/ldai_optimization/ld_api_client.py

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,28 @@
1010

1111
_BASE_URL = "https://app.launchdarkly.com"
1212

13+
14+
class LDApiError(Exception):
15+
"""Raised when the LaunchDarkly REST API returns an error or is unreachable.
16+
17+
Attributes:
18+
status_code: HTTP status code, or None for network-level failures.
19+
path: The API path that was requested.
20+
"""
21+
22+
def __init__(self, message: str, status_code: Optional[int] = None, path: str = "") -> None:
23+
super().__init__(message)
24+
self.status_code = status_code
25+
self.path = path
26+
27+
28+
_HTTP_ERROR_HINTS: Dict[int, str] = {
29+
401: "Authentication failed — check that LAUNCHDARKLY_API_KEY is set correctly.",
30+
403: "Authorization failed — check that your API key has the required permissions.",
31+
404: "Resource not found — check that the project key and optimization config key are correct.",
32+
429: "Rate limit exceeded — too many requests to the LaunchDarkly API.",
33+
}
34+
1335
_REQUIRED_STRING_FIELDS = ("id", "key", "aiConfigKey", "judgeModel")
1436
_REQUIRED_INT_FIELDS = ("maxAttempts", "version", "createdAt")
1537
_REQUIRED_LIST_FIELDS = (
@@ -170,12 +192,18 @@ def _request(self, method: str, path: str, body: Any = None) -> Any:
170192
return json.loads(raw) if raw else None
171193
except urllib.error.HTTPError as exc:
172194
body_excerpt = exc.read(500).decode(errors="replace")
173-
raise RuntimeError(
174-
f"LaunchDarkly API error {exc.code} for {method} {path}: {body_excerpt}"
195+
hint = _HTTP_ERROR_HINTS.get(exc.code, "")
196+
detail = f"{hint} (API response: {body_excerpt})" if hint else f"API response: {body_excerpt}"
197+
raise LDApiError(
198+
f"LaunchDarkly API error {exc.code} {exc.msg} for {method} {path}. {detail}",
199+
status_code=exc.code,
200+
path=path,
175201
) from exc
176202
except urllib.error.URLError as exc:
177-
raise RuntimeError(
178-
f"LaunchDarkly API request failed for {method} {path}: {exc.reason}"
203+
raise LDApiError(
204+
f"Could not reach LaunchDarkly API at {url}: {exc.reason}. "
205+
"Check your network connection and the base_url setting.",
206+
path=path,
179207
) from exc
180208

181209
def get_agent_optimization(
@@ -186,7 +214,7 @@ def get_agent_optimization(
186214
:param project_key: LaunchDarkly project key.
187215
:param optimization_key: Key of the agent optimization config.
188216
:return: Validated AgentOptimizationConfig.
189-
:raises RuntimeError: On non-200 HTTP responses or network errors.
217+
:raises LDApiError: On non-200 HTTP responses or network errors.
190218
:raises ValueError: If the response is missing required fields.
191219
"""
192220
path = f"/api/v2/projects/{project_key}/agent-optimizations/{optimization_key}"
@@ -208,7 +236,17 @@ def post_agent_optimization_result(
208236
path = f"/api/v2/projects/{project_key}/agent-optimizations/{optimization_id}/results"
209237
try:
210238
self._request("POST", path, body=payload)
211-
except Exception:
212-
logger.exception(
213-
"Failed to persist optimization result for optimization_id=%s", optimization_id
239+
except LDApiError as exc:
240+
logger.debug(
241+
"Failed to persist optimization result (optimization_id=%s, iteration=%s): %s",
242+
optimization_id,
243+
payload.get("iteration"),
244+
exc,
245+
)
246+
except Exception as exc:
247+
logger.debug(
248+
"Unexpected error persisting optimization result (optimization_id=%s, iteration=%s): %s",
249+
optimization_id,
250+
payload.get("iteration"),
251+
exc,
214252
)

packages/optimization/tests/test_client.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1105,9 +1105,16 @@ def test_acceptance_statements_and_judges_merged(self):
11051105
assert "acceptance-statement-0" in result.judges
11061106
assert "accuracy" in result.judges
11071107

1108-
def test_raises_when_no_judges_and_no_on_turn(self):
1108+
def test_raises_when_no_judges_no_ground_truth_no_on_turn(self):
11091109
config = dict(_API_CONFIG, acceptanceStatements=[], judges=[])
1110-
with pytest.raises(ValueError, match="no acceptance statements or judges"):
1110+
with pytest.raises(ValueError, match="no acceptance statements, judges, or ground truth"):
1111+
self._build(config=config)
1112+
1113+
def test_ground_truth_responses_alone_does_not_pass_no_criteria_check(self):
1114+
# groundTruthResponses is not yet implemented as standalone criteria;
1115+
# OptimizationOptions still requires judges or on_turn.
1116+
config = dict(_API_CONFIG, acceptanceStatements=[], judges=[], groundTruthResponses=["4"])
1117+
with pytest.raises((ValueError, Exception)):
11111118
self._build(config=config)
11121119

11131120
def test_on_turn_satisfies_no_judges_requirement(self):
@@ -1138,14 +1145,29 @@ def test_max_attempts_from_config(self):
11381145
result = self._build()
11391146
assert result.max_attempts == 3
11401147

1141-
def test_model_choices_from_config(self):
1148+
def test_model_choices_provider_prefix_stripped(self):
1149+
config = dict(_API_CONFIG, modelChoices=["OpenAI.gpt-4o", "Anthropic.claude-opus-4-5"])
1150+
result = self._build(config=config)
1151+
assert result.model_choices == ["gpt-4o", "claude-opus-4-5"]
1152+
1153+
def test_judge_model_provider_prefix_stripped(self):
1154+
config = dict(_API_CONFIG, judgeModel="OpenAI.gpt-4o")
1155+
result = self._build(config=config)
1156+
assert result.judge_model == "gpt-4o"
1157+
1158+
def test_model_choices_without_prefix_unchanged(self):
11421159
result = self._build()
11431160
assert result.model_choices == ["gpt-4o", "gpt-4o-mini"]
11441161

1145-
def test_judge_model_from_config(self):
1162+
def test_judge_model_without_prefix_unchanged(self):
11461163
result = self._build()
11471164
assert result.judge_model == "gpt-4o"
11481165

1166+
def test_model_with_multiple_dots_only_prefix_stripped(self):
1167+
config = dict(_API_CONFIG, judgeModel="Anthropic.claude-opus-4.6")
1168+
result = self._build(config=config)
1169+
assert result.judge_model == "claude-opus-4.6"
1170+
11491171
def test_callbacks_forwarded_from_options(self):
11501172
handle_agent = AsyncMock(return_value="ok")
11511173
handle_judge = AsyncMock(return_value=JUDGE_PASS_RESPONSE)

packages/optimization/tests/test_ld_api_client.py

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from ldai_optimization.ld_api_client import (
1313
AgentOptimizationConfig,
1414
LDApiClient,
15+
LDApiError,
1516
OptimizationResultPayload,
1617
_parse_agent_optimization,
1718
)
@@ -160,20 +161,42 @@ def test_authorization_header_always_sent(self):
160161
req: urllib.request.Request = mock_open.call_args[0][0]
161162
assert req.get_header("Authorization") == "my-api-key"
162163

163-
def test_raises_runtime_error_on_http_error(self):
164+
def test_raises_ld_api_error_on_http_error(self):
164165
client = LDApiClient("test-key")
165166
http_error = urllib.error.HTTPError(
166167
url="http://x", code=404, msg="Not Found", hdrs=MagicMock(), fp=BytesIO(b"not found body")
167168
)
168169
with patch("urllib.request.urlopen", side_effect=http_error):
169-
with pytest.raises(RuntimeError, match="404"):
170+
with pytest.raises(LDApiError) as exc_info:
170171
client._request("GET", "/missing")
172+
assert exc_info.value.status_code == 404
173+
assert "404" in str(exc_info.value)
171174

172-
def test_raises_runtime_error_on_url_error(self):
175+
def test_raises_ld_api_error_on_url_error(self):
173176
client = LDApiClient("test-key")
174177
url_error = urllib.error.URLError(reason="Connection refused")
175178
with patch("urllib.request.urlopen", side_effect=url_error):
176-
with pytest.raises(RuntimeError, match="Connection refused"):
179+
with pytest.raises(LDApiError) as exc_info:
180+
client._request("GET", "/path")
181+
assert exc_info.value.status_code is None
182+
assert "Connection refused" in str(exc_info.value)
183+
184+
def test_401_error_includes_api_key_hint(self):
185+
client = LDApiClient("test-key")
186+
http_error = urllib.error.HTTPError(
187+
url="http://x", code=401, msg="Unauthorized", hdrs=MagicMock(), fp=BytesIO(b"")
188+
)
189+
with patch("urllib.request.urlopen", side_effect=http_error):
190+
with pytest.raises(LDApiError, match="LAUNCHDARKLY_API_KEY"):
191+
client._request("GET", "/path")
192+
193+
def test_404_error_includes_key_hint(self):
194+
client = LDApiClient("test-key")
195+
http_error = urllib.error.HTTPError(
196+
url="http://x", code=404, msg="Not Found", hdrs=MagicMock(), fp=BytesIO(b"")
197+
)
198+
with patch("urllib.request.urlopen", side_effect=http_error):
199+
with pytest.raises(LDApiError, match="project key"):
177200
client._request("GET", "/path")
178201

179202
def test_custom_base_url_used_in_request(self):
@@ -218,14 +241,15 @@ def test_raises_on_invalid_response(self):
218241
with pytest.raises(ValueError, match="Invalid AgentOptimization response"):
219242
client.get_agent_optimization("proj", "opt")
220243

221-
def test_raises_runtime_error_on_http_404(self):
244+
def test_raises_ld_api_error_on_http_404(self):
222245
client = LDApiClient("test-key")
223246
http_error = urllib.error.HTTPError(
224247
url="http://x", code=404, msg="Not Found", hdrs=MagicMock(), fp=BytesIO(b"not found")
225248
)
226249
with patch("urllib.request.urlopen", side_effect=http_error):
227-
with pytest.raises(RuntimeError, match="404"):
250+
with pytest.raises(LDApiError) as exc_info:
228251
client.get_agent_optimization("proj", "missing-key")
252+
assert exc_info.value.status_code == 404
229253

230254

231255
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)