From 20dc231e60a4d34d21d808272e48f010714ef4cd Mon Sep 17 00:00:00 2001 From: Fernando Lazzarin Date: Tue, 26 May 2026 12:34:49 -0300 Subject: [PATCH] feat(providers): provider-neutral normalization for cross-model hook runs Adds the Claude-side substrate for the provider-invariance question (anthropics/claude-code#61167): normalize any provider's model turn into one NormalizedTurn {assistant_message, tool_calls} so the dark-pattern detectors run unchanged against OpenAI / Kimi K2 / etc. - providers/normalize.py: NormalizedTurn + adapters (claude_hook, openai_chat, openai_responses, kimi). Pure stdlib, no network. - providers/fixtures.py + conformance_test.py: same logical turn in 4 envelopes -> identical assistant_message, identical tool_calls, IDENTICAL count_drift verdict (16/16). Claimed-but-uncalled dispatch -> tool_calls==[] on every envelope. - providers/CONTRACT.md + README.md: schema, mapping table, conformance bar so @yurukusa's live Kimi/OpenAI adapter has a spec. Formats verified live 2026-05-26; two items flagged (Claude last_assistant_message version-gating, Task tool_input opacity) and routed around. Additive; no existing file changed. Co-Authored-By: Claude Opus 4.7 (1M context) --- providers/CONTRACT.md | 61 ++++++++++++++ providers/README.md | 37 +++++++++ providers/SPEC.md | 151 ++++++++++++++++++++++++++++++++++ providers/conformance_test.py | 84 +++++++++++++++++++ providers/fixtures.py | 97 ++++++++++++++++++++++ providers/normalize.py | 121 +++++++++++++++++++++++++++ 6 files changed, 551 insertions(+) create mode 100644 providers/CONTRACT.md create mode 100644 providers/README.md create mode 100644 providers/SPEC.md create mode 100644 providers/conformance_test.py create mode 100644 providers/fixtures.py create mode 100644 providers/normalize.py diff --git a/providers/CONTRACT.md b/providers/CONTRACT.md new file mode 100644 index 0000000..1f394fc --- /dev/null +++ b/providers/CONTRACT.md @@ -0,0 +1,61 @@ +# Provider normalization contract + +The `llm-dark-patterns` detectors read two things from a model turn: the assistant's +**closeout text** and the **tool calls it actually emitted**. To run them against models +other than Claude (the provider-invariance question — does operator-side discipline +generalize across models?), each provider's raw transcript is normalized to one shape. + +## `NormalizedTurn` + +``` +NormalizedTurn: + assistant_message: str # the model's final/closeout text only + tool_calls: list[ {name: str, arguments: str|dict} ] # calls the model actually emitted +``` + +- `assistant_message` is the answer text only — never reasoning/CoT, never tool output. +- `tool_calls` is what crossed the tool boundary. A turn that *claims* a dispatch in text + but emits no call has `tool_calls == []` — that gap is the dispatch-fabrication signal. + +## Adapter interface + +An adapter is a pure function `raw_payload (dict) -> NormalizedTurn`. No network, no deps. +Register it in `normalize.ADAPTERS[name]`. Current adapters: `claude_hook`, +`openai_chat`, `openai_responses`, `kimi`. + +## Mapping table (formats verified live 2026-05-26) + +| Field | Claude Code hook | OpenAI Chat | OpenAI Responses | Kimi / Moonshot | +|---|---|---|---|---| +| assistant text | `.last_assistant_message` (¹) | `choices[0].message.content` | `output[].type=="message"` → `content[].output_text.text` | `choices[0].message.content` (NOT `reasoning_content`) | +| tool calls | PreToolUse `tool_name`/`tool_input`, or transcript `tool_use` blocks (²) | `choices[0].message.tool_calls[].function.{name,arguments}` | `output[].type=="function_call"` → flat `{name, arguments}` | OpenAI-compatible `tool_calls`; skip `type=="builtin_function"` server echoes | + +¹ **Flagged (version-gated):** `last_assistant_message` appears in 2026 community Stop-hook +implementations but was not rendered on the official hooks page fetched 2026-05-26. The +adapter falls back to the last assistant turn in an inline `messages`/`transcript`. + +² **Flagged (insufficient_data):** the exact `tool_input` key names for the `Task`/Agent +dispatch tool were not quoted in the official docs; `arguments` is treated as an opaque +object (detectors use the tool *name* and *count*, not its argument schema). + +## Conformance bar (what a NEW adapter must pass) + +`providers/conformance_test.py` must stay green after adding an adapter. Concretely, for +the same logical turn expressed in your provider's envelope: + +1. `assistant_message` equals the logical text (answer only — exclude reasoning/CoT). +2. `tool_names()` equals the set of calls actually emitted (skip server-side/builtin echoes). +3. `lib/count_drift.py` (or any text detector) returns the SAME verdict as on the Claude + envelope — this is the provider-invariance property the substrate exists to prove. +4. A claimed-but-uncalled dispatch yields `tool_calls == []`. + +Fixtures in `providers/fixtures.py` are documented-format synthetic; a real adapter +should additionally be validated against live-captured responses (that live validation is +the adapter author's responsibility — e.g. @yurukusa's Kimi K2 / OpenAI adapter). + +## Status / scope + +This is the offline normalization layer for cross-model EVAL. It does not re-plumb the +hooks' hot path (they keep their Claude-hook entry) and it makes no API calls. A labeled +cross-model corpus and actual per-model F1 numbers are a separate follow-up; this contract +is the prerequisite that makes that pass runnable. diff --git a/providers/README.md b/providers/README.md new file mode 100644 index 0000000..a39e000 --- /dev/null +++ b/providers/README.md @@ -0,0 +1,37 @@ +# providers/ — run the dark-pattern detectors cross-model + +This directory is the Claude-side substrate for **provider-invariance**: normalizing any +provider's model turn into one shape so the `llm-dark-patterns` detectors run unchanged +against OpenAI / Kimi K2 / etc., not just Claude. It is the prerequisite for a cross-model +F1 pass (the question from anthropics/claude-code#61167: *does operator-side discipline +generalize across models, or lean on Claude-specific guard behavior?*). + +## Files +- `normalize.py` — `NormalizedTurn` + adapters (`claude_hook`, `openai_chat`, + `openai_responses`, `kimi`). Pure stdlib, no network. +- `fixtures.py` — the same logical turns in each provider's documented raw envelope. +- `conformance_test.py` — proves cross-envelope equivalence + identical detector verdicts. +- `CONTRACT.md` — the schema, mapping table, and the conformance bar for a new adapter. + +## Run +``` +python3 providers/conformance_test.py # exit 0 = all envelopes agree +``` + +## Adding an adapter (e.g. a live Kimi K2 / OpenAI adapter) +1. Write `raw_payload -> NormalizedTurn` in `normalize.py`; register in `ADAPTERS`. +2. Add your provider's raw envelope to each logical turn in `fixtures.py`. +3. Keep `conformance_test.py` green: same `assistant_message`, same `tool_names()`, same + `count_drift` verdict as the Claude envelope; claimed-but-uncalled dispatch → `[]`. +4. Validate against live-captured responses on your side (the synthetic fixtures here are + format-faithful, not live-captured). + +The intended division of labor (per #61167): this contract + the Claude reference adapter +are maintained here; @yurukusa's Kimi K2 / OpenAI tool-format adapter conforms to it, and +the falsifiable provider-invariance F1 pass becomes runnable once a labeled cross-model +corpus exists. + +## What this is not +Not a hot-path rewrite (the hooks keep their Claude-hook entry), not a network client, and +not the F1 result itself — it is the offline normalization layer that makes the cross-model +comparison possible. diff --git a/providers/SPEC.md b/providers/SPEC.md new file mode 100644 index 0000000..c7d00e0 --- /dev/null +++ b/providers/SPEC.md @@ -0,0 +1,151 @@ +# SPEC — providers/: provider-neutral normalization for cross-model hook runs + +Status: ACTIVE (pre-implementation). Author: waitdeadai. Date: 2026-05-26. +Origin: the provider-invariance thread with @yurukusa (anthropics/claude-code#61167) +and @nvst18's request to trial non-Claude models under the same runtime verification. +This is the Claude-side substrate contract that lets the `llm-dark-patterns` detectors +run against transcripts from other providers — the prerequisite for any cross-model F1 +pass. @yurukusa builds the live Kimi K2 / OpenAI adapter against this contract. + +## 1. Problem Statement + +The suite's detectors consume Claude Code's hook JSON (`.last_assistant_message`, tool +events). To test provider-invariance ("does operator-side discipline generalize across +models?") the detectors must run against OpenAI / Kimi K2 transcripts too. There is no +provider-neutral shape today, so the substrate cannot run a second-model pass. + +## 2. Success Criteria (measurable) + +- **SC1 (normalization):** `providers/normalize.py` maps raw fixtures from Claude Code + hook JSON, OpenAI Chat Completions, OpenAI Responses, and Kimi/Moonshot into a single + `NormalizedTurn` (`assistant_message: str`, `tool_calls: [{name, arguments}]`). + Verified by `providers/conformance_test.py` asserting field-level equivalence. +- **SC2 (provider-invariance proof — the load-bearing one):** the SAME logical closeout + expressed in every supported envelope normalizes to the same `assistant_message`, and + running an existing detector (`lib/count_drift.py`) over each yields an IDENTICAL + verdict. A count-drift positive → `block` on all envelopes; a negative → `pass` on all. + This demonstrates the detector is invariant to the provider envelope (the substrate + property the whole thread needs). Verified by the conformance test. +- **SC3 (tool-call extraction):** for a turn that emits tool calls, every adapter yields + the same `tool_calls` name list; for a turn that CLAIMS a dispatch in text but emits no + call, `tool_calls == []` on every envelope (the dispatch-fabrication signal — what + @yurukusa's dispatch-receipt needs cross-model). Verified by fixtures. +- **SC4 (no deps, no network):** pure Python stdlib; no API calls; runs offline. + Verified by inspection + the test running with no network. +- **SC5 (contract documented):** `providers/CONTRACT.md` specifies the `NormalizedTurn` + schema, the adapter interface, the per-provider mapping table, and the conformance bar + a NEW adapter must pass — so @yurukusa's adapter has a spec. The two research-uncertain + items (Claude `last_assistant_message` is version-gated with a transcript-parse + fallback; the `Task` tool_input key names are opaque) are flagged explicitly. +- **SC6 (no regression):** additive under `providers/`; the bundled-plugin smoke and + stress CI still pass. + +Non-criteria: live API calls to OpenAI/Kimi (out — needs creds; @yurukusa owns the live +adapter); a labeled cross-model corpus / actual F1 numbers (out — separate follow-up). + +## 3. Scope + +**In scope:** +- `providers/normalize.py` — `NormalizedTurn` dataclass + `from_claude_hook`, + `from_openai_chat`, `from_openai_responses`, `from_kimi` (Kimi reuses the OpenAI path + with documented quirks). Pure stdlib. +- `providers/fixtures/` — the same logical turns (a count-drift positive, a clean + negative, a dispatch-claim-without-call) in each provider's raw envelope. +- `providers/conformance_test.py` — asserts SC1/SC2/SC3 (cross-envelope equivalence + + identical detector verdicts + tool-call extraction). +- `providers/CONTRACT.md` + `providers/README.md` — schema, mapping table, adapter + conformance bar, how @yurukusa's Kimi/OpenAI adapter plugs in, flagged uncertainties. + +**Out of scope (this PR):** +- Live API calls / network (creds; @yurukusa's live adapter). +- A cross-model labeled corpus and actual provider-invariance F1 numbers (follow-up once + this substrate + a corpus exist). +- Re-plumbing every hook to read `NormalizedTurn` (the hooks keep their Claude-hook entry + path; this is the offline normalization layer for cross-model EVAL, not a hot-path + rewrite). + +## 4. Design + +`NormalizedTurn`: +- `assistant_message: str` — the model's final/closeout text (what text detectors read). +- `tool_calls: list[dict]` — `[{ "name": str, "arguments": str|dict }]`, the calls the + assistant actually emitted (count + names; for dispatch-fabrication / count work). + +Mapping (from research, accessed 2026-05-26; live-verified unless flagged): +- **Claude Code hook**: `assistant_message` ← `.last_assistant_message` (version-gated; + fallback: parse `.transcript_path` JSONL for the last assistant turn — FLAGGED + uncertain S1/S3); `tool_calls` ← PreToolUse `tool_name`/`tool_input` or transcript + tool-use blocks (`Task` tool_input keys opaque — FLAGGED insufficient_data). +- **OpenAI Chat Completions**: `assistant_message` ← `choices[0].message.content`; + `tool_calls` ← `choices[0].message.tool_calls[].function.{name, arguments}`. +- **OpenAI Responses**: `assistant_message` ← output text items; `tool_calls` ← + `function_call` items `{name, arguments}` (note `call_id`, flat name/arguments). +- **Kimi/Moonshot**: OpenAI-compatible `tool_calls`; quirks: semantic ids (`"search:0"`), + `type:"builtin_function"` server-executed echoes, `reasoning_content` separate from + `content` (must not be folded into `assistant_message`). + +Divergences that matter: (a) function vs tool nomenclature; (b) Responses flat vs Chat +nested arguments; (c) Kimi `reasoning_content` must be excluded from `assistant_message`; +(d) "claimed dispatch, no call" looks identical across all → `tool_calls == []` (the +fabrication signal). + +## 5. Agent-Native Estimate + +- Estimate type: agent-native wall-clock. +- Topology: local single loop (one tightly-coupled normalizer + test); not parallelizable. +- Capacity evidence: local-bound; lanes don't reduce the critical path. +- Critical path: SPEC → /specqa → /introspect → normalize.py → fixtures → conformance + test → /verify. +- Agent wall-clock: optimistic ~5 build/verify cycles, likely ~8, pessimistic ~12 (if + envelope quirks need a tuning pass). +- Agent-hours: low. Human touch time: review + merge. Calendar blockers: none (additive, + feature branch, no `.github/workflows` touched → no scope wall). +- Confidence: medium — downgrade reason: two research-flagged uncertainties (Claude + `last_assistant_message` version-gating, `Task` tool_input opacity); the design routes + around both (fallbacks + opaque treatment), so they don't block. + +## 6. Implementation Plan + +### Task 1: `providers/normalize.py` +DoD: `NormalizedTurn` dataclass; the four adapters; Kimi excludes `reasoning_content`; +graceful on missing fields (returns empty message / empty tool_calls, never raises). + +### Task 2: `providers/fixtures/` +DoD: three logical turns × four envelopes: (a) count-drift positive ("Six findings:" + 5 +items), (b) clean negative, (c) dispatch-claim-without-call. Raw JSON per envelope. + +### Task 3: `providers/conformance_test.py` +DoD: asserts SC1 (equivalent NormalizedTurn across envelopes), SC2 (identical +`count_drift.analyze` verdict across envelopes), SC3 (tool-call name lists match; +fabrication fixture → `tool_calls == []` everywhere). Bash/py harness, exits 0/1. + +### Task 4: `providers/CONTRACT.md` + `README.md` +DoD: schema, mapping table, adapter conformance bar, plug-in instructions for @yurukusa's +adapter, flagged uncertainties. + +## 7. Verification + +- SC1/SC2/SC3 → `python3 providers/conformance_test.py` exits 0. +- SC2 specifically → the count-drift positive yields `block` on Claude/OpenAI-chat/ + OpenAI-responses/Kimi envelopes; the negative yields `pass` on all (printed). +- SC4 → inspection: no imports beyond stdlib; test runs with no network. +- SC6 → bundled-plugin smoke + stress CI green on the PR. + +## 8. Rollback Plan + +1. Isolated on `feature/providers-shim`; nothing on `main` until merge. +2. Purely additive under `providers/` — no existing file changed; deleting the dir is a + complete rollback. +3. `git revert ` post-merge; `git branch -D feature/providers-shim` pre-merge. +4. Verify rollback: bundled-plugin smoke passes; `ls providers/` absent. + +## Source ledger (deepresearch, accessed 2026-05-26) + +- OpenAI Chat Completions / Responses tool-call shapes — live-verified, official OpenAI docs. +- Kimi/Moonshot tool calls — live-verified OpenAI-compatible with `builtin_function` + + semantic-id + `reasoning_content` quirks. +- Claude Code hook input (`hook_event_name`, `stop_hook_active`, PreToolUse + `tool_name`/`tool_input`/`tool_use_id`) — live-verified, official hooks doc. +- FLAGGED uncertain: `last_assistant_message` in Stop/SubagentStop (version-gated per + community impls; not on the official page fetched) → transcript-parse fallback; + `Task` tool_input key names → insufficient_data → treated as opaque. diff --git a/providers/conformance_test.py b/providers/conformance_test.py new file mode 100644 index 0000000..21470d7 --- /dev/null +++ b/providers/conformance_test.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""providers/conformance_test.py — provider-invariance conformance. + +Asserts that every supported envelope normalizes the same logical turn to the same +`NormalizedTurn`, that an existing detector (`lib/count_drift.py`) returns an IDENTICAL +verdict across envelopes (the substrate-level provider-invariance property), and that a +dispatch claimed in text but never emitted as a call yields `tool_calls == []` on every +envelope (the dispatch-fabrication signal). Pure stdlib, no network. Exit 0/1. +""" +import importlib.util +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..")) + + +def _load(name, path): + spec = importlib.util.spec_from_file_location(name, path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +norm = _load("normalize", os.path.join(HERE, "normalize.py")) +fx = _load("fixtures", os.path.join(HERE, "fixtures.py")) +cd = _load("count_drift", os.path.join(ROOT, "lib", "count_drift.py")) + +PASS = 0 +FAIL = 0 +FAILS = [] + + +def check(cond, desc): + global PASS, FAIL + if cond: + PASS += 1 + print(" PASS %s" % desc) + else: + FAIL += 1 + FAILS.append(desc) + print(" FAIL %s" % desc) + + +for fid, spec in fx.FIXTURES.items(): + logical = spec["logical"] + norms = {p: norm.ADAPTERS[p](raw) for p, raw in spec["envelopes"].items()} + n_env = len(norms) + # SC1: assistant_message identical across envelopes and equal to the logical text. + check(all(n.assistant_message == logical["text"] for n in norms.values()), + "[%s] assistant_message identical across %d envelopes" % (fid, n_env)) + # SC3: tool-call names identical across envelopes and equal to the logical set. + check(all(n.tool_names() == logical["tool_names"] for n in norms.values()), + "[%s] tool_calls identical across envelopes (== %s)" % (fid, logical["tool_names"])) + # SC2: count_drift verdict identical across envelopes. + verdicts = {p: cd.analyze(n.assistant_message)["decision"] for p, n in norms.items()} + check(len(set(verdicts.values())) == 1, + "[%s] count_drift verdict identical across envelopes (%s)" + % (fid, sorted(set(verdicts.values())))) + +# SC2 sharper: the positive blocks and the negative passes, each through a NON-Claude envelope. +pos = norm.from_openai_chat(fx.FIXTURES["countdrift_positive"]["envelopes"]["openai_chat"]) +neg = norm.from_kimi(fx.FIXTURES["clean_negative"]["envelopes"]["kimi"]) +check(cd.analyze(pos.assistant_message)["decision"] == "block", + "count-drift positive -> block via OpenAI envelope") +check(cd.analyze(neg.assistant_message)["decision"] == "pass", + "clean negative -> pass via Kimi envelope") + +# Fabrication: claimed dispatch, zero tool calls on every envelope (Kimi builtin echo skipped). +fab = fx.FIXTURES["dispatch_fabricated"]["envelopes"] +check(all(norm.ADAPTERS[p](raw).tool_calls == [] for p, raw in fab.items()), + "dispatch fabrication: tool_calls==[] on every envelope (the fabrication signal)") + +# Kimi reasoning_content must not leak into the answer text. +kimi_pos = norm.from_kimi(fx.FIXTURES["countdrift_positive"]["envelopes"]["kimi"]) +check("internal CoT" not in kimi_pos.assistant_message, + "kimi reasoning_content excluded from assistant_message") + +print("\nPASS=%d FAIL=%d" % (PASS, FAIL)) +if FAIL: + print("FAILURES: " + "; ".join(FAILS)) + sys.exit(1) +print("ALL CONFORMANCE CHECKS PASSED") +sys.exit(0) diff --git a/providers/fixtures.py b/providers/fixtures.py new file mode 100644 index 0000000..455c49f --- /dev/null +++ b/providers/fixtures.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""providers/fixtures.py — the same logical turns expressed in each provider's raw +envelope, for the conformance test. Envelopes follow the documented 2026 formats +(OpenAI Chat/Responses, Kimi/Moonshot, Claude Code hook). Synthetic but format-faithful; +live-captured validation of a real adapter is @yurukusa's side. + +Each entry: id -> {"logical": {"text", "tool_names"}, "envelopes": {provider: raw}}. +""" + +_POS = "Six findings:\n- a\n- b\n- c\n- d\n- e" # count-drift positive (6 vs 5) +_NEG = "Five findings:\n- a\n- b\n- c\n- d\n- e" # clean negative (5 vs 5) +_FAB = "I dispatched CLINIC, GUARD and SABRINA — all three reviews complete." # claim, no call +_REAL = "Dispatched the reviewer agent." # real tool call +_ARGS = '{"subagent_type":"reviewer"}' + + +def _claude_text(t): + return {"hook_event_name": "Stop", "stop_hook_active": False, "last_assistant_message": t} + + +def _claude_tooluse(t, name): + return {"hook_event_name": "Stop", "messages": [ + {"role": "assistant", "content": [ + {"type": "text", "text": t}, + {"type": "tool_use", "name": name, "input": {"subagent_type": "reviewer"}}, + ]}]} + + +def _openai_chat(t, tools=None): + msg = {"role": "assistant", "content": t} + if tools: + msg["tool_calls"] = [{"id": "call_1", "type": "function", + "function": {"name": tools, "arguments": _ARGS}}] + return {"choices": [{"message": msg, "finish_reason": "stop"}]} + + +def _openai_responses(t, tools=None): + out = [{"type": "message", "role": "assistant", + "content": [{"type": "output_text", "text": t}]}] + if tools: + out.append({"type": "function_call", "call_id": "fc_1", "name": tools, "arguments": _ARGS}) + return {"output": out} + + +def _kimi(t, tools=None, builtin_echo=False): + msg = {"role": "assistant", "content": t, + "reasoning_content": "(internal CoT that must NOT leak into the answer)"} + tcs = [] + if builtin_echo: # server-executed echo that must be skipped + tcs.append({"id": "search:0", "type": "builtin_function", + "function": {"name": "$web_search", "arguments": "{}"}}) + if tools: + tcs.append({"id": "tc_1", "type": "function", + "function": {"name": tools, "arguments": _ARGS}}) + if tcs: + msg["tool_calls"] = tcs + return {"choices": [{"message": msg, "finish_reason": "stop"}]} + + +FIXTURES = { + "countdrift_positive": { + "logical": {"text": _POS, "tool_names": []}, + "envelopes": { + "claude_hook": _claude_text(_POS), + "openai_chat": _openai_chat(_POS), + "openai_responses": _openai_responses(_POS), + "kimi": _kimi(_POS), + }, + }, + "clean_negative": { + "logical": {"text": _NEG, "tool_names": []}, + "envelopes": { + "claude_hook": _claude_text(_NEG), + "openai_chat": _openai_chat(_NEG), + "openai_responses": _openai_responses(_NEG), + "kimi": _kimi(_NEG), + }, + }, + "dispatch_fabricated": { # text claims a dispatch; NO tool call emitted anywhere + "logical": {"text": _FAB, "tool_names": []}, + "envelopes": { + "claude_hook": _claude_text(_FAB), + "openai_chat": _openai_chat(_FAB), + "openai_responses": _openai_responses(_FAB), + "kimi": _kimi(_FAB, builtin_echo=True), # builtin echo must be skipped -> still [] + }, + }, + "dispatch_real": { # one real Task call; every adapter must surface exactly ["Task"] + "logical": {"text": _REAL, "tool_names": ["Task"]}, + "envelopes": { + "claude_hook": _claude_tooluse(_REAL, "Task"), + "openai_chat": _openai_chat(_REAL, tools="Task"), + "openai_responses": _openai_responses(_REAL, tools="Task"), + "kimi": _kimi(_REAL, tools="Task", builtin_echo=True), # echo skipped, Task kept + }, + }, +} diff --git a/providers/normalize.py b/providers/normalize.py new file mode 100644 index 0000000..a1b1bc5 --- /dev/null +++ b/providers/normalize.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""providers/normalize.py — provider-neutral transcript normalization. + +Maps a raw model-turn payload from different providers into one `NormalizedTurn` +so the llm-dark-patterns detectors (which read the assistant's closeout text and the +tool calls it actually emitted) can run cross-model. Pure standard library, no network. + +See providers/CONTRACT.md for the schema and the per-provider mapping table. +""" +from dataclasses import dataclass, field + + +@dataclass +class NormalizedTurn: + assistant_message: str = "" + tool_calls: list = field(default_factory=list) # [{"name": str, "arguments": str|dict}] + + def tool_names(self): + return [str(tc.get("name", "")) for tc in self.tool_calls] + + +def _s(x): + return x if isinstance(x, str) else "" + + +def from_claude_hook(payload): + """Claude Code Stop / SubagentStop / PreToolUse hook JSON. + + assistant_message <- `.last_assistant_message` (version-gated; falls back to the last + assistant turn in an inline `.messages`/`.transcript` if present — see CONTRACT.md). + tool_calls <- a PreToolUse `tool_name`/`tool_input`, and/or `tool_use` blocks in an + inline transcript. `Task`/Agent tool_input keys are treated as an opaque object. + """ + d = payload if isinstance(payload, dict) else {} + msg = _s(d.get("last_assistant_message")) + tcs = [] + if d.get("tool_name"): + tcs.append({"name": str(d.get("tool_name")), "arguments": d.get("tool_input", {})}) + msgs = d.get("messages") or d.get("transcript") or [] + if isinstance(msgs, list): + for m in msgs: + if not isinstance(m, dict) or m.get("role") != "assistant": + continue + content = m.get("content") + if isinstance(content, str): + if not msg: + msg = content + elif isinstance(content, list): + for block in content: + if not isinstance(block, dict): + continue + if block.get("type") == "text" and not msg: + msg = _s(block.get("text")) + elif block.get("type") == "tool_use": + tcs.append({"name": str(block.get("name", "")), + "arguments": block.get("input", {})}) + return NormalizedTurn(assistant_message=msg, tool_calls=tcs) + + +def from_openai_chat(payload): + """OpenAI Chat Completions response.""" + d = payload if isinstance(payload, dict) else {} + choices = d.get("choices") or [] + msg, tcs = "", [] + if choices and isinstance(choices[0], dict): + m = choices[0].get("message") or {} + msg = _s(m.get("content")) + for tc in (m.get("tool_calls") or []): + fn = (tc or {}).get("function") or {} + tcs.append({"name": str(fn.get("name", "")), "arguments": fn.get("arguments", "")}) + return NormalizedTurn(assistant_message=msg, tool_calls=tcs) + + +def from_openai_responses(payload): + """OpenAI Responses API response (output items; flat function_call name/arguments).""" + d = payload if isinstance(payload, dict) else {} + parts, tcs = [], [] + for item in (d.get("output") or []): + if not isinstance(item, dict): + continue + t = item.get("type") + if t == "function_call": + tcs.append({"name": str(item.get("name", "")), "arguments": item.get("arguments", "")}) + elif t == "message" or item.get("role") == "assistant": + content = item.get("content") + if isinstance(content, str): + parts.append(content) + elif isinstance(content, list): + for c in content: + if isinstance(c, dict) and c.get("type") in ("output_text", "text"): + parts.append(_s(c.get("text"))) + if not parts and isinstance(d.get("output_text"), str): + parts.append(d["output_text"]) + return NormalizedTurn(assistant_message="".join(parts), tool_calls=tcs) + + +def from_kimi(payload): + """Kimi / Moonshot — OpenAI-compatible Chat shape, with two quirks: + `reasoning_content` is NOT part of the answer (excluded), and server-executed + `builtin_function` tool-call echoes (type != "function") are skipped. + """ + d = payload if isinstance(payload, dict) else {} + choices = d.get("choices") or [] + msg, tcs = "", [] + if choices and isinstance(choices[0], dict): + m = choices[0].get("message") or {} + msg = _s(m.get("content")) # deliberately NOT reasoning_content + for tc in (m.get("tool_calls") or []): + if (tc or {}).get("type") not in (None, "function"): + continue # skip builtin_function server-side echoes + fn = (tc or {}).get("function") or {} + tcs.append({"name": str(fn.get("name", "")), "arguments": fn.get("arguments", "")}) + return NormalizedTurn(assistant_message=msg, tool_calls=tcs) + + +ADAPTERS = { + "claude_hook": from_claude_hook, + "openai_chat": from_openai_chat, + "openai_responses": from_openai_responses, + "kimi": from_kimi, +}