Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ EvalRunResult [class, @dataclass]
url: str
EvalTool [class, @dataclass(frozen=True)]
__init__(key: str, implementation: ToolImplementation, schema: dict[str, Any], description: str = '')
__post_init__() -> None
description: str = ''
implementation: ToolImplementation
key: str
Expand Down
14 changes: 14 additions & 0 deletions packages/client/src/launchdarkly_ai_server/evaluations/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,20 @@ def require_mapping(value: Any, *, description: str) -> Mapping[str, Any]:
return value


def check_string_keys(value: Any) -> None:
"""Raise ``TypeError`` on a mapping key that is not a ``str``, recursively."""
if isinstance(value, Mapping):
for key, item in value.items():
if not isinstance(key, str):
raise TypeError(
f"keys must be str, not {type(key).__name__} (got {key!r})"
)
check_string_keys(item)
elif isinstance(value, (list, tuple)):
for item in value:
check_string_keys(item)


def require_string(data: Mapping[str, Any], key: str, description: str) -> str:
"""Return the non-empty string at ``key``. Raises ``EvaluationsError``."""
value = data.get(key)
Expand Down
14 changes: 2 additions & 12 deletions packages/client/src/launchdarkly_ai_server/evaluations/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
EvaluationsError,
LDApiClient,
Transport,
check_string_keys,
segment,
urllib_transport,
)
Expand Down Expand Up @@ -62,17 +63,6 @@ def _render_inline_row(row: DatasetRow) -> DatasetRow:
)


def _check_string_keys(value: Any) -> None:
if isinstance(value, Mapping):
for key, item in value.items():
if not isinstance(key, str):
raise TypeError(f"keys must be str, not {type(key).__name__}")
_check_string_keys(item)
elif isinstance(value, (list, tuple)):
for item in value:
_check_string_keys(item)


def _normalize_inline_rows(rows: Sequence[InlineDatasetRow]) -> list[DatasetRow]:
"""Validate caller-supplied rows, returning them raw and indexed by position.

Expand Down Expand Up @@ -126,7 +116,7 @@ def _normalize_inline_rows(rows: Sequence[InlineDatasetRow]) -> list[DatasetRow]
# references fail there instead of recursing in the key check.
try:
json.dumps(value, allow_nan=False)
_check_string_keys(value)
check_string_keys(value)
except (TypeError, ValueError) as error:
raise EvaluationsError(
f"Inline dataset row {position} {field_name} must be "
Expand Down
48 changes: 37 additions & 11 deletions packages/client/src/launchdarkly_ai_server/evaluations/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import asyncio
import copy
import json
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass, field
Expand All @@ -13,6 +14,7 @@
EvaluationsError,
LDApiClient,
LDApiError,
check_string_keys,
require_mapping,
segment,
)
Expand All @@ -38,6 +40,9 @@ class EvalTool:
a library tool, because the provider supplies its schema.
``dataclasses.replace`` on a library tool returns an inline tool that
keeps the library schema.

The tool holds a deep copy of ``schema``, so changing the caller's dict
afterwards changes neither what is validated nor what is sent.
"""

key: str
Expand All @@ -48,6 +53,16 @@ class EvalTool:
version: int | None = field(default=None, init=False)
project_key: str | None = field(default=None, init=False)

def __post_init__(self) -> None:
# A schema that is not a mapping, or cannot be copied, is left as it
# is for validate_tools to reject with the tool key.
if isinstance(self.schema, Mapping):
try:
schema = copy.deepcopy(dict(self.schema))
except (TypeError, copy.Error):
return
object.__setattr__(self, "schema", schema)

@classmethod
def _library(
cls,
Expand Down Expand Up @@ -76,7 +91,9 @@ def to_create_wire(self) -> dict[str, Any]:
if self.source == "inline":
return {
"key": self.key,
"schema": self.schema,
# A copy, so a handler that changes its config cannot change
# the body.
"schema": copy.deepcopy(self.schema),
"description": self.description,
"source": "inline",
}
Expand Down Expand Up @@ -120,7 +137,11 @@ def _validate_inline_tool(tool: EvalTool) -> None:
)
try:
# allow_nan=False rejects NaN and Infinity, which are not valid JSON.
# Non-str keys are rejected rather than coerced; json.dumps runs first
# so circular references fail there instead of recursing in the key
# check.
json.dumps(dict(tool.schema), allow_nan=False)
check_string_keys(tool.schema)
except (TypeError, ValueError) as error:
raise EvaluationsError(
f"Inline tool {key!r} schema must be JSON-serializable: {error}"
Expand All @@ -133,7 +154,7 @@ def validate_tools(tools: Sequence[EvalTool], project_key: str) -> None:
Checks that each library tool has a project and that it matches
``project_key``. Issues no requests.
"""
keys_by_identity: dict[str, str] = {}
tools_by_identity: dict[str, EvalTool] = {}
for tool in tools:
if not isinstance(tool, EvalTool):
raise EvaluationsError(
Expand Down Expand Up @@ -161,17 +182,18 @@ def validate_tools(tools: Sequence[EvalTool], project_key: str) -> None:
f"cannot run in project {project_key!r}. Read it from "
f"{project_key!r} instead."
)
# One key names one tool. Keys are compared case-insensitively.
identity = key.strip().lower()
collision = keys_by_identity.get(identity)
# One key names one tool. Never pick one entry silently: the run record
# would then misdescribe where the model's tool came from. casefold,
# unlike lower, also matches keys such as "straße" and "strasse".
identity = key.strip().casefold()
collision = tools_by_identity.get(identity)
if collision is not None:
if collision == key:
raise EvaluationsError(f"Tool {key!r} appears more than once in tools.")
raise EvaluationsError(
f"Tool {key!r} collides with {collision!r}. Two tools in one "
"run must not have keys that differ only by case."
f"Tool {key!r} ({tool.source}) collides with {collision.key!r} "
f"({collision.source}). A key may appear once in tools, compared "
"without case."
)
keys_by_identity[identity] = key
tools_by_identity[identity] = tool


def tool_handlers(tools: Sequence[EvalTool]) -> dict[str, ToolImplementation]:
Expand All @@ -182,7 +204,11 @@ def tool_handlers(tools: Sequence[EvalTool]) -> dict[str, ToolImplementation]:
def handler_config_tools(tools: Sequence[EvalTool]) -> dict[str, dict[str, Any]]:
"""Return the ``tools`` entry of a handler config."""
return {
tool.key: {"description": tool.description, "parameters": tool.schema}
# A copy, so a handler that changes its config cannot change the body.
tool.key: {
"description": tool.description,
"parameters": copy.deepcopy(tool.schema),
}
for tool in tools
}

Expand Down
Loading
Loading