-
[OPIK-7172] [BE][FE][SDK] feat: re-expose custom-code metric in Optimization Studio (#7460)
发布于
2026-07-21 10:04:22 +00:00 - [OPIK-7172] [BE][FE][SDK] feat: re-expose custom-code metric in Optimization Studio
Re-expose the custom-code metric (METRIC_TYPE.CODE) in the v2 Optimization
Studio new-run flow as a first-class, validated option, with proper error
surfacing.Backend (python + java):
- Fix build-time false-rejection: validate custom code via compile()/exec +
instantiate to read the metric name WITHOUT calling score() (was running
score() with an empty payload, wrongly rejecting metrics that require dataset
fields). Error taxonomy mirrors the online-eval sandbox. - Rename-capable arguments map (param -> dataset column): isolated_metric builds
score() kwargs from the map; for strict signatures (no **kwargs) only mapped
params + output are passed (no full-splat), preserving **kwargs back-compat. - Persist an error_info field on the Optimization record (ClickHouse migration
000102 + DAO carry-through on status-only re-inserts + model/update DTOs);
mark_error(message) threads the reason; blank messages don't clobber.
Frontend (v2):
- Re-add the "Custom code" metric option.
- Client-side Python syntax validation via the CodeMirror/Lezer grammar.
- score() signature -> arg/column mapping UI with dataset-column validation;
kwargs.get("x", default) no longer treated as a required column. - Error callout + wire the run-page Logs panel on ERROR status.
- Recommend kwargs.get(...) in helper copy; consolidate the code template.
SDK: thread error_info through optimizations update + optimizer finalize.
Tests: BE unit (syntax error, missing BaseMetric, runtime->0.0, rename with
extra columns) + e2e negatives; FE unit for the validation blocks.Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- [OPIK-7172] fix: CI — ruff-format optimizer + updateById test errorInfo
- ruff-format base_optimizer.py + test_base_optimizer_finalize.py (lint check).
- OptimizationsResourceTest.updateById: errorInfo is now an updatable field on
OptimizationUpdate, so the expected optimization must reflect the applied
value (update value if non-null, else keep existing) — mirrors the name/status
assertion and the DAO's <if(error_info)> update semantics.
Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- [OPIK-7172] fix: surface error status when pinned opik SDK lacks error_info
Root cause of the stuck-"running" e2e failure: the python-backend pins a
released opik (1.10.56) whose typed update_optimizations_by_id has no
error_infokwarg. On a build-time failure, mark_error(error_info=...) raised
TypeError, which optimization_lifecycle swallows — so no "error" row was
written and the run hung at "running".Fix: status update tries the typed call, and on TypeError falls back to the
SDK's pre-configured httpx client (raw PUT) so error_info still reaches the
backend (snake_case + ignoreUnknown). Forward-compatible: once the SDK ships
error_info, the typed call handles it and the fallback is never used.Reproduced end-to-end against a local branch backend (migration 000102 +
error_info column): before, the run stayed "running" with no error row; after,
it reaches "error" with the reason persisted, and the e2e test passes.Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- [OPIK-7172] fix: rename optimizations error_info migration 000102 -> 000103
main merged 000102_add_harness_to_cipx_trace_identity after this branch was
cut, so 000102_add_error_info_to_optimizations collided (check-migration-prefix-
conflicts). Bump to the next free prefix 000103 + matching changeset id.Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- [OPIK-7172] fix: address review — static build validation, strict-signature safety
Baz review findings:
- [high] In-process exec of untrusted code: build-time validate_user_code now
validates purely viaast.parse(no exec / no init), so no untrusted
user code runs at build; score() at scoring time still honors
PYTHON_CODE_EXECUTOR_STRATEGY. AST also yields the metric name, **kwargs
presence, and score() param names. - [high] Strict code metrics masked as 0.0: isolated_metric now branches on the
**kwargs signature — a strict score() gets ONLY its declared params (resolved
via the arguments map or a same-name column), never the full dataset splat —
whether or not an arguments map is present. Prevents the extra-column
TypeError -> swallowed 0.0. - [low] Invalid mapping types: non-string 'arguments' entries are dropped before
the dataset-key lookup. - [high/defensive] mapToDto defaults error_info to "" for robustness on reads.
Tests: 18 code-metric unit tests pass (added strict-no-map + non-string-args
guards); syntax-error e2e still green (reproduced against a local branch backend).Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- [OPIK-7172] refactor: optimization error_info as structured ErrorInfo
Address review (alexkuzmik): the run failure reason now uses the existing
structured ErrorInfo type {exception_type, message, traceback} — the same type
spans/traces use — instead of a plain string, so it's consistent and richer to
render.- Java: Optimization/OptimizationUpdate error_info String -> ErrorInfo; DAO
serializes to / reads from the ClickHouse String column as JSON
(JsonUtils + ERROR_INFO_TYPE), migration unchanged. - python-backend: optimization_lifecycle builds {exception_type, message,
traceback} from the caught exception; status_manager sends the structured dict
(raw-REST fallback preserved for the pinned older SDK). - SDK: rest_api optimization types + update param -> Optional[ErrorInfo];
base_optimizer finalize builds the structured dict. - FE: Optimization.error_info -> BaseTraceDataErrorInfo; RunErrorPanel prefers
the structured error_info.message over log-scraping (falls back to logs).
Verified: Java OptimizationsResourceTest#updateById 3/0/0 (testcontainers),
python-backend unit 263 passed, SDK finalize 20 passed, FE tsc/eslint/vitest
green. e2e + fern regen deferred to CI.Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- [OPIK-7172] fix: e2e reads structured error_info as dict (pinned SDK)
The syntax-error e2e accessed optimization.error_info.message, but the
python-backend's pinned (released) opik SDK returns error_info as a plain dict
(extra field) rather than a typed ErrorInfo object -> AttributeError. Read the
message/traceback via a helper that handles both a dict and a future typed
object. The structured error_info is persisted correctly; only the test's
access was wrong.Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- [OPIK-7172] fix: address review — blank error message + validate ErrorInfo
- RunErrorPanel: an empty error_info.message no longer short-circuits the
??
chain (blank panel); use?.trim() ||so it falls back to the log-derived
message. - OptimizationUpdate: mark errorInfo
@Validso the nested ErrorInfo @NotBlank
constraints cascade on the update request body.
Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- [OPIK-7172] [FE] style: prettier-wrap errorMessage useMemo in RunErrorPanel
Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- [OPIK-7172] address alexkuzmik review: metric-name idioms, aliased BaseMetric, old-SDK fallback
- process_worker._metric_name_ast: recover name from super().init(name="...")
(the common no-name-param idiom) so the UI no longer shows "code" for it - process_worker._find_basemetric_classdef: resolve aliased BaseMetric imports
(from ... import BaseMetric as BM) so class X(BM) is detected at build time - optimization.update(): on old pinned opik lacking the error_info kwarg, fall
back to a raw REST PUT (mirrors worker status_manager) so the status/error
transition still lands instead of throwing and stalling the run - tests: super().init name idiom + aliased-import build
Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- [OPIK-7172] self-review fixes: submit-gate accuracy + consistent class selection
Code-review (full, workflow) surfaced three genuine correctness gaps in the
custom-code-metric submit gate and backend signature detection:- optimizations.ts: default-less kwargs.get("x") no longer marks a column
REQUIRED. .get() is missing-safe (returns None) and the editor helper copy
recommends it as the safe accessor, so blocking submit contradicted our own
guidance and blocked valid runs. Only kwargs["x"] subscripts gate submit. - optimizations.ts/useOptimizationsNewFormHandlers.ts: the gate now also
requires a strict score() signature's positional params (no **kwargs) via
new extractRequiredScoreParams — an unmapped param with no same-named column
would raise a missing-arg TypeError at runtime (silent all-zero run) that the
kwargs-only scan missed. - process_worker._find_basemetric_classdef: when a file declares multiple metric
classes, pick the alphabetically-first (matching get_metric_class's
inspect.getmembers order) so the statically-inferred score() signature flags
match the class actually instantiated at scoring time.
Tests added for all three. Left as intentional/documented: no build-time exec
(security), dynamic metric-name -> "code" fallback, fabricated graceful-error
traceback (@NotBlank), output-precedence flip (LLM output correctly wins).Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- [OPIK-7172] fix: rename error_info migration 000103 -> 000105 (main merged 000103/000104)
main added 000103_add_config_to_cipx_spends and 000104_add_plan_to_cipx_trace_identities
after this branch's 000103, so check-migration-prefix-conflicts failed. Bump ours to
the next free prefix and its changeset id to match.Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- [OPIK-7172] address Baz review: base-ctor name scan, **kwargs required params, ApiError contract
- process_worker._metric_name_ast: only read the name from the base constructor
call (super().init or a declared-base init), not any incidental
Helper.init(name=...) in the metric body (was misnaming objectives). - optimizations.extractRequiredScoreParams: a trailing **kwargs no longer drops
required declared positional params (kwargs absorbs only undeclared extras, so
a missing declared param still raises TypeError). Return [] (defer to backend)
when >1 score() def makes class-selection ambiguous. Parse-error stays fail-open
(best-effort pre-check; backend AST is authoritative). - optimization.update() fallback: translate a non-2xx raw-PUT response into the
SDK's ApiError (status_code/headers/body) instead of leaking
httpx.HTTPStatusError, preserving the error contract for .
Tests added for all three.
Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- [OPIK-7172] fix: FE metric-name extractor mirrors backend AST (objective_name parity)
Baz: the backend AST name extractor now recognizes super().init(name=...),
declared-base ctor calls, and class-level name=..., but the create path derived
objective_name from a FE regex that only matched the init param default. So
a metric using super().init(name="real") was stored as objective_name="code"
while its scores were emitted under "real" -> the v2 UI keyed by "code" and showed
"-". Broaden extractMetricNameFromPythonCode to mirror the backend precedence:
base constructor -> init param default -> class attribute (self.name guarded).
Also benefits the online-eval rule score-name path that shares this extractor.Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- [OPIK-7172] address review: cascade errorInfo validation, preserve on upsert, structure-aware FE name extractor
Backend (thiagohora findings):
- Optimization.errorInfo and updateOptimizationsById request param now carry
@Valid so ErrorInfo's @NotBlank constraints cascade at the request boundary
(mirrors studioConfig / the create+upsert endpoints). - OptimizationService.upsert preserves an existing persisted failure reason when
a re-upsert arrives with a null errorInfo, matching the studioConfig
preservation branch (upsert is a full-row replace). - OptimizationDAO.mapToDto guards error_info deserialization in try/catch-log-null
like studio_config, so a malformed value degrades the field instead of failing
the whole row. - Added an errorInfo-only case to the updateById MethodSource, covering the
branch that persists a failure reason without name/status.
Frontend (baz findings):
- extractMetricNameFromPythonCode now strips comments and triple-quoted
docstrings before matching, so a decoy super().init(name=...) / name=...
in a comment or docstring no longer injects a fake metric name. - The class-level
name = "..."fallback is scoped to the class-body
indentation level, matching the backend AST (ast.Assign in cls.body) and
excluding method-local assignments. Added regression tests.
Also renamed the error_info migration 000105 -> 000106 to resolve the prefix
conflict with main's 000105_add_id_at_to_spans.Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- [OPIK-7172] test: cover error_info preservation on SDK re-upsert
Adds an integration test that records a failure reason via the PATCH/update
path, then re-upserts the optimization with a null errorInfo and asserts the
persisted reason survives the full-row replace (the OptimizationService.upsert
preservation branch added for thiagohora's finding).Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- [OPIK-7172] address baz re-review: multi-class name extractor, malformed-source + clobber test coverage
- rules.ts extractClassBodyName now scans every class declaration (bounded to
each class body), so a helper class declared before the metric class no longer
causes the class-levelname = "..."to be missed. - FE tests: added a malformed-Python case (unterminated docstring -> null) and a
helper-class-before-metric case. - Backend test errorInfoPreservedOnReUpsert now also asserts studioConfig is not
dropped by the full-row-replace re-upsert (status ordering is last_updated_at
governed, so intentionally not asserted).
Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- [OPIK-7172] fix: FE metric-name extractor resolves the same class the backend instantiates
The previous multi-class extractor returned the first
name = "..."found across
all class bodies, so a helper (or sibling) class could win and yield a metric name
that doesn't match the scored name — worse than null, because getFeedbackScore(...,
objective_name) then misses AND expectedMetricNames polls for a name that never
arrives (until MAX_REFETCH_TIME), rendering a phantom expected metric.Now mirror the backend (process_worker._find_basemetric_classdef / get_metric_class):
- Collect BaseMetric aliases (literal +
import ... asalias). - Select the class whose declared bases include BaseMetric (name, dotted, or alias);
when several, the alphabetically-first (Python min-by-name) — the same class the
backend instantiates. - Extract the name from ONLY that class body, in precedence order
(super().init(name=) -> init default -> class-body-level name = "..."). - If no BaseMetric subclass is identifiable, return null and defer to the backend
(which would reject non-BaseMetric code anyway) rather than guess a wrong name.
Added regression tests: helper-class-with-own-name doesn't win, alphabetical
selection among multiple metric classes, import-alias and dotted base recognition,
and null when no BaseMetric subclass is resolvable.Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- fix: renumber optimizations error_info migration to 000107 (prefix conflict with main)
Co-Authored-By: Claude Fable 5 noreply@anthropic.com
- [OPIK-7172] address review: multiline alias imports, PEP 695 headers, init-scoped name extraction; migration trailing newline
- rules.ts collectBaseMetricAliases handles parenthesized multiline imports
- class header regex accepts PEP 695 type parameters (class MT:)
- base-constructor name extraction scoped to the metric class's own init,
accepting super().init and declared-base/alias init calls, ignoring
helper-object init calls — mirrors process_worker._metric_name_ast - 000107 migration ends with the required empty line
Co-Authored-By: Claude Fable 5 noreply@anthropic.com
- [OPIK-7172] fix: metric-name extractor captures balanced constructor args
Address baz finding (rules.ts:262): the
super().__init__(...)/ base-ctor
argument capture used[^)]*, which stops at the first). A nested call
before the name kwarg — e.g.super().__init__(config=make_cfg(), name="foo")
— was truncated, sonamewasn't found and the extractor fell back to "code",
storing a wrong objective_name (getFeedbackScore misses, expectedMetricNames
polls until MAX_REFETCH_TIME).Match only the ctor opening and capture the FULL balanced argument list (via a
new balancedArgs helper that respects nested parens + string literals), then
find the name kwarg in it. Added regression tests for a nested call before and
after the name kwarg.Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- [OPIK-7172] review: narrow mapToDto JSON catch to UncheckedIOException
Address thiagohora finding (OptimizationDAO:1006): the error_info (and adjacent
studio_config) deserialization caught bare Exception, masking unexpected runtime
failures (e.g. NPEs) alongside the expected JSON parse error. JsonUtils.readValue
throws UncheckedIOException on a malformed payload, so catch that narrowly and
let real bugs surface; the row-id context in the log is preserved.Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- [OPIK-7172] fix: only inject output into code metrics that accept it
Address thiagohora finding (metrics.py:894): the LLM
outputwas injected into
score()'s kwargs unconditionally. For a strict signature that doesn't declare an
outputparam (e.g.def score(self, reference)), that arrives as an
unexpected keyword -> TypeError -> swallowed to a masked ScoreResult(0.0) — the
exact OPIK-7172 failure mode the strict-signature handling was added to remove.Inject
outputonly when the signature can accept it:accepts_var_keyword or "output" in score_params. Added a regression test for a strict metric that does
not declareoutput.Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- [OPIK-7172] docs: correct arguments-map docstring to match code
Address thiagohora finding (metrics.py:799): the docstring said **kwargs metrics
receive the remaining columns "minus those consumed as a rename source", but the
code splats every column (data = {**dataset_item}) and only overlays the
renamed param — so a rename source IS still present under its original name. Also
refreshed theoutputbullet to match the now-conditional injection (a strict
signature without anoutputparam does not receive it).Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- [OPIK-7172] fix: keep traceback tail (not head) when truncating error_info
Address thiagohora finding (status_manager:94): message/traceback were truncated
with value[:MAX], keeping the HEAD. Python tracebacks put the innermost frame and
the actual raise site at the END, so for long tracebacks the persisted reason
showed only outer runner frames and cut off the frames closest to the failure —
undercutting OPIK-7172's whole point (surfacing the failure reason).Now keep the message head (short, meaningful at its start) but the traceback
TAIL, with a "...[traceback truncated]..." marker, capped at MAX_ERROR_INFO_LENGTH.
The caller's dict is copied, never mutated.Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- [OPIK-7172] fix: make update_status error_info/metadata keyword-only
Address thiagohora finding (status_manager:61): the merge inserted error_info
between status and metadata, changing the 2nd positional's meaning — a future
positional callerupdate_status("completed", some_metadata)would silently
bind some_metadata to error_info. All in-repo callers already use keywords, so
make error_info/metadata keyword-only (*) to remove the trap.Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- [OPIK-7172] test: cover error_info send / suppression / truncation
Address thiagohora finding (status_manager:89): the error_info path had no unit
coverage (only touched indirectly by e2e). Added TestErrorInfoForwarding:- mark_error({...}) routes through the raw client with error_info in the body;
- mark_error(None)/mark_error({}) sends NO error_info (status-only, typed path)
so it can't clobber a previously persisted reason; - an over-length message is head-truncated and an over-length traceback keeps
its tail (innermost frame) under the cap, without mutating the caller's dict.
Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- [OPIK-7172] fix: align build-time metric-class selection with runtime
Address thiagohora findings (process_worker _find_basemetric_classdef vs
get_metric_class, metrics.py:848):- get_metric_class now restricts to classes DEFINED in the module
(cls.__module__ == module.__name__), so an IMPORTED concrete BaseMetric
subclass (e.g.from opik.evaluation.metrics import Equals) is never
instantiated instead of the user's class just because its name sorts first (#6). - _find_basemetric_classdef now resolves INDIRECT subclasses transitively over
classes defined in the file, so it selects the same alphabetically-first class
runtimeissubclasspicks — no more build/runtime divergence that applies the
wrong signature/name (silent 0.0 / "-") (#2). - validate_user_code no longer hard-rejects when the only BaseMetric link is
through an IMPORTED base (statically unresolvable but instantiable at runtime);
it defers with permissive defaults when a class is defined, and still rejects a
file with no class at all (#1 regression guard).
Added tests: transitive indirect-subclass selection matches runtime, imported
subclass is not instantiated, imported-base defers instead of rejecting, no-class
still rejected.Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- [OPIK-7172] fix: FE metric-class resolver follows indirect subclass chains
Keep the FE objective_name extractor in sync with the backend selector change:
findMetricClassBody now resolves INDIRECT (transitive) BaseMetric subclasses
defined in the file — collecting all class declarations, seeding from direct
alias subclasses, then transitively adding classes that subclass an
already-known metric class — before picking the alphabetically-first. This
matches process_worker._find_basemetric_classdef / get_metric_class, so an
indirect subclass that runtime instantiates yields the same objective_name here
(no "-"/stalled-polling mismatch). Unresolvable (imported-base) cases still
return null and defer to the backend. Added a transitive-subclass test.Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- [OPIK-7172] review: read real signature in imported-base fallback + build/score test
Address baz follow-ups on the review batch:
- process_worker.validate_user_code: when no class statically resolves to a
BaseMetric subclass but classes are defined, read name + signature flags from
the alphabetically-first defined class that declares score() (mirrors runtime
get_metric_class), instead of returning blanket permissive defaults. This keeps
a STRICT imported-base metric from being force-splatted into a masked 0.0 and
preserves its objective_name. Only when no defined class declares score() (it's
inherited from the imported base) do we fall back to permissive defaults. - Strengthened tests: the imported-base case now builds via MetricFactory.build
and asserts the real ScoreResult value/name (Equals-based), plus a direct
validate_user_code assertion that the strict signature is read (not blanket).
Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- [OPIK-7172] review: restrict metric-class selection to top-level classes
Address baz finding (process_worker:362): _find_basemetric_classdef and the
imported-base fallback collected ClassDefs via ast.walk, which includes classes
NESTED in a method/class. Those never become module attributes, so runtime
get_metric_class (inspect.getmembers) never sees them — but a nested helper with
a score() sorting alphabetically first could be picked at build, disagreeing
with runtime. Both now use _top_level_classdefs (tree.body only), matching
runtime's module-level view. Added a regression test: a nested AHelper.score()
must not override the top-level metric's strict signature.Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- [OPIK-7172] chore: bump error_info migration 000107 -> 000108 (prefix conflict)
main merged 000107_apply_traces_local_v2_real_data_codec_refinements; rename the
optimizations error_info analytics migration to the next free prefix (000108) and
update its changeset id to clear the migration-prefix-conflict check.Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
Co-authored-by: Claude Opus 4.8 (1M context) noreply@anthropic.com
Co-authored-by: Yaroslav Boiko yaroslavb@comet.com下载附件