Skip to content

Sdk build#63

Merged
secxena merged 25 commits into
stagingfrom
sdk-build
May 2, 2026
Merged

Sdk build#63
secxena merged 25 commits into
stagingfrom
sdk-build

Conversation

@secxena

@secxena secxena commented May 1, 2026

Copy link
Copy Markdown
Contributor

No description provided.

secxena and others added 25 commits April 30, 2026 12:05
Two pre-flight specs that block Plan 2 Phase 0 (soth-sdk-core scaffolding).
Neither is implementation; both lock the public-API contracts the SDK will
ship in customer dependencies. Once Phase 0 commits the types, every
change to these surfaces is breaking for downstream code.

docs/common/SDK_DECISION_API_SPEC.md (~560 lines)
- Decision enum: Allow / Block / Redact / Flag (Reroute dropped — proxy-only)
- BlockReason variants including UseAlternative for reroute-shaped policies
- MessageRedactions: message-level replacement only; within-message edits
  are documented as proxy-only
- DecisionToken lifecycle: created in pre_call, consumed once in
  post_call, slab-backed, panic-on-reuse-in-debug, log-and-ignore in
  release, 60s orphan sweeper, sentinel for slab-full
- Wrapper exception contract per language: SothBlocked extends base
  Exception/Error (NOT openai.APIError / etc.), propagates past existing
  try/except API-error handlers; gating negative tests required per binding
- Sync (≤5ms p99) vs async (≤300ms p99) budget with explicit allowed work
- Conformance harness extension: compare_decisions() asserts variant +
  BlockReason kind + redact message_idx set; proxy_only fixture tagging

docs/common/SDK_WASM_TRUST_BOUNDARY_SPEC.md (~450 lines)
- ORT-Web spike result: realistic full-mode payload is 22-27MB compressed,
  not the original ≤8MB target (which was wrong)
- Tier matrix: native = full local; browser/Bun/Lambda = full WASM;
  CF Workers / Vercel Edge = reduced; Fastly = full
- ClassificationMode enum: Full | Reduced | CloudOptIn (no auto-promotion)
- Reduced-mode capability statement: explicit list of what works
  (artifact redaction, counter-based anomalies, artifact policy,
  session dedup, telemetry shipping) and what doesn't (use_case_label,
  semantic anomalies, ML-conditioned org rules)
- Cloud-classify wire format: explicit opt-in with separate DPA, never
  fallback, classification_location: "cloud" telemetry tag
- Bundle CDN trust path: Ed25519 verify, hot-swap via ArcSwap, no
  on-disk cache option for serverless, no key-fetching endpoint
- Conformance lanes: SDK-via-PyO3, SDK-via-WASM-reduced,
  SDK-via-WASM-cloud-optin (with stubbed local endpoint for hermetic CI)

Both specs include "If this needs to change" sections so the lock-in is
deliberate, not accidental.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New workspace member crates/soth-sdk-core/ — the public-API surface every
SDK binding (PyO3, napi-rs, WASM) consumes. Implements the contract locked
by the two pre-flight specs landed in d68fb20:
  - docs/common/SDK_DECISION_API_SPEC.md
  - docs/common/SDK_WASM_TRUST_BOUNDARY_SPEC.md

Public types (all #[non_exhaustive] where future variants are anticipated):
  Decision           — Allow / Block / Redact / Flag (no Reroute)
  BlockReason        — SensitiveArtifact / BudgetExceeded / PolicyRule /
                       UseAlternative
  MessageRedactions  — message-level only; within-message edits are
                       proxy-only by design
  DecisionToken      — opaque, Copy, slab-backed; SLAB_FULL +
                       SENTINEL_FAIL_OPEN constants for backpressure /
                       fail-open
  FlagSeverity       — Info / Warning / Critical
  HmacKey            — FromEnv / FromFile / FromCallback / Static;
                       resolve() returns Zeroizing<Vec<u8>>;
                       Debug never prints plaintext
  ClassificationMode — Full / Reduced / CloudOptIn
  StorageMode        — InMemory / FileBacked
  BundleSource       — Cdn / Embedded / Fallback
  SdkConfig + SdkConfigBuilder
  LlmCall (= TypedLlmCall), Message, Tool, LlmResponse, LlmChunk
  Observation, StreamObservation
  SdkError + SothSdk

DecisionToken slab (src/slab.rs):
  4096-slot fixed capacity, 95% pressure threshold returns SLAB_FULL
  per-slot generation counter detects token reuse
  panic-on-reuse in debug, log-and-ignore in release per spec §5.3
  bounded in_use atomic for lock-free pressure check

SothSdk facade (src/sdk.rs):
  init                — validates HMAC key + ClassificationMode + bundle
                        source; fails closed
  pre_call            — synchronous decision (≤5ms p99 budget per spec
                        §7.1); runs process_normalized + sync block path
                        on credential/private-key artifacts; allocates
                        slab token
  post_call           — consumes token, runs full classify with fallback
                        bundle, pushes TelemetryEvent onto in-memory queue
  stream_begin /
    stream_chunk /
    stream_end        — token-consumed-once invariant for streaming
  refresh_bundle      — Phase-1 stub
  in_flight_decisions /
    drain_telemetry_for_test — test hooks

In-memory telemetry queue (src/telemetry_queue.rs):
  bounded VecDeque (4096 default), oldest-drops-on-overflow with
  dropped-count metric. Phase-1 wires HTTPS shipper.

Compile-time Send + Sync assertions for SothSdk + Observation —
bindings stash Arc<SothSdk> across host worker threads.

Tests: 15 unit + 5 integration round-trip
  - init_succeeds_with_minimal_config
  - pre_call_then_post_call_balances_slab_and_emits_telemetry
  - pre_call_blocks_on_credential_in_user_message
  - streaming_round_trip_consumes_token_once
  - many_pre_calls_without_post_call_do_not_leak_past_slab_capacity
    (asserts SLAB_FULL behavior under 6K alloc pressure)

Verification:
  - cargo build --workspace clean
  - all workspace lib tests pass (634 + 20 new = 654)
  - All 4 WASM combos (detect+classify x wasip1+unknown-unknown) clean
  - Conformance harness still green (7/7 fixtures, 0 strict failures)

Phase-1 deferred (called out in crate README):
  - Real bundle CDN pull + Ed25519 verification
  - Background HTTPS telemetry shipper
  - Cloud-classify wire transport
  - Orphan sweeper for never-consumed tokens
  - Full org-rule evaluation
  - OTel span emission
  - Conformance harness lane through this facade

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…crate calls

Phase 0 follow-up. The conformance harness (PR 5) ran proxy and SDK lanes
through the lower-level crates directly. Phase 0 added soth-sdk-core as
the public API facade, which is now an extra layer where parity can drift.
This commit closes that gap.

soth-sdk-core:
- Add SothSdk::for_test(config, detect_bundle, classify_bundle) — doc-hidden
  ctor that bypasses the bundle-source dispatch so the harness can isolate
  facade-vs-direct parity from bundle-source differences. Phase-1's `init`
  will gain an in-memory BundleSource variant that subsumes this.

soth-conformance-tests:
- run_facade_lane(fixture, bundles) — drives SothSdk::pre_call → post_call,
  drains the in-memory telemetry queue, returns the Decision + emitted
  TelemetryEvent.
- compare_sdk_vs_facade(sdk_lane, facade) — strict-parity comparison on
  the cloud ingestion contract surface (provider, model, endpoint_type,
  capture_mode, parse_source, parse_confidence, use_case, volatility_class,
  policy_kind, estimated_input_tokens, anomaly_flags). Per-call ephemeral
  fields (event_id, timestamp_epoch_ms, commitment_nonce, commitment_hash)
  are documented as excluded.
- New test sdk_direct_and_facade_lanes_agree_byte_identical asserts every
  fixture produces a TelemetryEvent matching the SDK direct lane's
  classify output. Failure names the diverging field so the fix lands in
  soth-sdk-core, not the harness.

Dependency: soth-conformance-tests now depends on soth-sdk-core via path.

Verification:
- All 7 fixtures pass the new sdk-vs-facade parity assertion
- Existing proxy-vs-sdk parity still passes (7/7 strict, 2 advisory)
- soth-sdk-core: 15 unit + 5 integration tests
- soth-detect / soth-classify / soth-proxy lib tests unchanged
- All 4 WASM combos (detect+classify x wasip1+unknown-unknown) clean

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bindings/soth-py/ — Python binding consuming soth-sdk-core via PyO3.
maturin-built abi3-py310 wheel publishable as `soth` on PyPI.

Public Python surface (python/soth/__init__.py):
  init(api_key, org_id, hmac_key_env=...) — module-level singleton
  guard(call_fn, call=...)                 — pre/post lifecycle wrapper
  SothBlocked                              — exception (NOT openai.APIError)
  SothFlagged                              — Flag surface
  BlockReason                              — typed reason

Negative tests (tests/test_blocked_propagates.py) gate the contract:
  - test_soth_blocked_does_not_inherit_from_openai_apierror
  - test_soth_blocked_propagates_past_openai_apierror_handler
  - test_soth_blocked_inherits_from_base_exception_directly
If any future change makes SothBlocked inherit from openai.APIError or
any provider type, these tests fail immediately. See SDK_DECISION_API_SPEC.md §6.

soth-sdk-core: added DecisionToken::raw() / from_raw() so bindings can
round-trip the token across the FFI boundary without inspecting struct
internals. The token is still opaque per spec §3.4.

Cargo / build:
  - bindings/soth-py added to workspace members but NOT default-members
    (cdylib + cdylib needs Python toolchain to actually link)
  - `pyo3/extension-module` is a soth-py feature, not always-on, so
    `cargo build -p soth-py` succeeds for compile-checking. maturin
    passes the feature when building wheels.
  - Python 3.10 floor (3.9 reached EOL October 2025)
  - abi3 — one wheel per platform×arch covers Python 3.10+

Verification:
  cargo build -p soth-py clean
  All workspace lib tests: 654 (unchanged)
  Conformance harness: 7/7 fixtures (unchanged)
  Phase 0 SDK core round-trip tests: 5/5 (unchanged)

Phase-1 follow-ups documented in bindings/soth-py/README.md:
  auto-instrumentation, httpx middleware, with-context, Redact handling,
  streaming wrapper, per-arch wheel matrix via cibuildwheel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bindings/soth-node/ — Node.js binding consuming soth-sdk-core via napi-rs.
@napi-rs/cli builds prebuilt binaries per platform/arch; published to
npm as @soth/sdk.

Public TS surface (index.js + index.d.ts):
  init({ apiKey, orgId, hmacKeyEnv })   — module-level singleton
  guard(asyncFn, { call })              — pre/post lifecycle wrapper
  SothBlocked extends Error             — NOT extends OpenAI.APIError
  SothFlagged                           — Flag surface
  BlockReason                           — typed reason interface

Negative tests (__test__/blocked-propagates.test.mjs) gate the contract:
  - SothBlocked does not extend openai.APIError  (instanceof check)
  - SothBlocked propagates past try { ... } catch (OpenAI.APIError) blocks
  - SothBlocked extends Error directly
Per SDK_DECISION_API_SPEC.md §6.3, customers' retry-on-API-error logic
must NOT silently retry policy blocks. The negative test will fail loudly
if the inheritance changes.

DecisionToken round-trip: tokens are stringified u64 across the FFI
because JS numbers can't safely hold the full u64 range. Bindings parse
the string back into the u64 via DecisionToken::from_raw, which was
added in this commit's companion soth-sdk-core change.

Cargo / build:
  - bindings/soth-node added to workspace members, NOT default-members
  - napi-rs 2.x with napi8 ABI feature (Node 18+ supported)
  - `cargo build -p soth-node` compiles the cdylib clean
  - Production binaries built via `npm run build` per the
    @napi-rs/cli triples in package.json (linux-x64-{gnu,musl},
    aarch64-linux-{gnu,musl}, darwin-arm64, darwin-x64, win32-x64)

Verification:
  cargo build -p soth-node clean
  cargo build --workspace clean (both bindings included)
  All workspace lib tests unchanged

Phase-1 follow-ups documented in bindings/soth-node/README.md:
  auto-instrumentation, undici dispatcher, withContext helper,
  AsyncIterable streaming wrapper, per-arch loader logic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both bindings gain a streaming surface that mirrors the Decision API
spec §4.2 (`stream_begin → stream_chunk → stream_end`). Streaming was
the keystone gap before auto-instrumentation: ~70% of production LLM
traffic streams, so without this the SDK was silently broken on the
hot path.

Python (bindings/soth-py):
- PyStreamObservation napi class wrapping CoreStreamObservation in
  Mutex<Option<>> for take-on-end semantics.
- New PyO3 method SothSdk.stream_begin(call) -> (decision, observation).
- python/soth/__init__.py adds:
    async def guard_stream(iter_factory, *, call, chunk_extractor=None)
  Async generator; raises SothBlocked on Block decision; default
  chunk extractor reads OpenAI's chunk.choices[0].delta.content shape.
- tests/test_streaming.py: 3 round-trip tests (consume-once, block on
  credential, double-end idempotent).
- Type stubs updated in _soth_native.pyi.

Node (bindings/soth-node):
- napi class can't compose with napi(object) wrappers, so observations
  are kept on SothSdk keyed by token (raw u64 stringified across FFI).
  streamBegin returns just the JsDecision; streamChunk(token, ...) and
  streamEnd(token) look up by token.
- index.js adds `guardStream(iterFactory, { call, chunkExtractor })`
  async generator with the same semantics as the Python version.
  Default extractor is OpenAI-shaped.
- index.d.ts: GuardStreamOptions + ChunkExtractorOutput types.
- __test__/streaming.test.mjs: 3 tests mirroring Python.

Sentinel handling: SLAB_FULL / SENTINEL_FAIL_OPEN tokens skip the
observation slab — there's nothing to stash because pre_call didn't
allocate. streamEnd on a sentinel is a no-op.

Verification:
- cargo build --workspace clean
- All workspace lib tests unchanged (654)
- Conformance harness 7/7 fixtures still pass

Phase-1 follow-ups still deferred:
- chunk_extractor presets for anthropic/cohere/google/mistral
- Auto-instrumentation hooking streamBegin/streamChunk/streamEnd
- Streaming wrapper for non-AsyncIterator callbacks (Anthropic's
  message_stream_text style)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Connects the SDK's in-memory telemetry queue to soth-cloud over HTTPS.
Without this commit, post_call emitted events into a queue nobody read;
now configured deployments actually deliver telemetry to the dashboard.

soth-sdk-core:
- New `http-telemetry` feature (off by default to keep workspace
  builds fast). When on, pulls reqwest with rustls-tls.
- New module crates/soth-sdk-core/src/shipper.rs:
    TelemetryShipper::spawn(queue, endpoint, api_key, org_id)
  spawns a `soth-telemetry-shipper` thread that drains MAX_BATCH_SIZE
  events every BATCH_WINDOW (5s), POSTs to the configured endpoint
  with bearer auth, and Drop's cleanly. Final drain on shutdown
  ensures events buffered during the last window are flushed.
- TelemetryQueue::drain_batch(max) — bounded pull for the shipper.
- SothSdk holds a Mutex<Option<TelemetryShipper>>; init spawns it
  iff `telemetry_endpoint` is set, drop-on-shutdown is idempotent.
- SothSdk::shutdown() — public method bindings call at process exit.

V0 limits (Phase-2 follow-ups documented in shipper.rs):
- No retry / circuit-breaker (failed batches drop)
- No exp backoff
- No dead-letter queue
- No batch compression / signing / encryption

Bindings:
- bindings/soth-py and soth-node both enable http-telemetry by default
  (production native bindings always want a working shipper).
- Both expose `telemetry_endpoint` parameter on init.
- Both expose `shutdown()` method for graceful exit.
- Python: soth.init(..., telemetry_endpoint="https://...") and
  soth.shutdown().
- Node:   soth.init({ telemetryEndpoint: "https://..." }) and
  soth.shutdown().

Verification:
- cargo build -p soth-sdk-core (default, http-telemetry off): clean
- cargo build -p soth-sdk-core --features http-telemetry: clean
- cargo build --workspace clean (bindings pull http-telemetry on)
- soth-sdk-core 15 unit + 5 integration tests unchanged
- Conformance harness 7/7 fixtures unchanged
- The for_test() ctor never spawns a shipper so CI stays hermetic

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rage

Multi-tenant story: customers can override identity (user_id_hmac,
team_id, device_id_hash, session_id, request_id) per-call rather than
relying on SDK init defaults. Async-aware context propagation is
language-native: Python contextvars, Node AsyncLocalStorage.

soth-sdk-core:
- New module crates/soth-sdk-core/src/context.rs with `CallContext`
  struct (#[non_exhaustive], all-Option fields, builder methods).
- New `pre_call_with_context(call, &CallContext)` and
  `stream_begin_with_context(call, &CallContext)` methods. Old
  `pre_call` / `stream_begin` delegate with default context.
- DecisionContext (slab) carries the resolved CallContext from
  pre_call to post_call so identity asserted at decision time matches
  what telemetry emits.
- build_proxy_context now reads resolved context for user_id_hmac /
  team_id / device_id_hash / session_id; falls back to SdkConfig
  defaults when CallContext leaves a field None. Session ID is parsed
  as UUID where possible, otherwise dropped (proxy-shaped contract).
- HMAC discipline preserved: user_id_hmac is the HMAC; the SDK never
  sees plaintext user IDs.

Python (bindings/soth-py):
- soth.context(*, user_id_hmac=, team_id=, device_id_hash=,
  session_id=, request_id=) is a contextmanager backed by
  contextvars.ContextVar so it survives asyncio task switches.
- guard() and guard_stream() pull the current context and pass it to
  the FFI on every call.
- PyO3 layer: pre_call(call, context=None) / stream_begin(call,
  context=None) accept an optional dict; build_call_context translates
  to CallContext.
- Nested `with soth.context(...)` blocks merge — fields not set in
  inner block fall through to outer.

Node (bindings/soth-node):
- soth.withContext(overrides, async () => ...) backed by
  AsyncLocalStorage so async code awaited inside sees the same
  context across awaits.
- guard() and guardStream() pull the current AsyncLocalStorage value
  and pass it to the napi FFI.
- napi-rs layer: preCall(call, context?) / streamBegin(call, context?)
  accept an optional JsCallContext; build_call_context translates.
- Nested withContext calls merge identically to the Python contract.

Verification:
- cargo build --workspace clean
- soth-sdk-core 15 unit + 5 integration tests
- Conformance harness 7/7 fixtures
- All workspace lib tests unchanged
- All 4 WASM combos clean
- Both bindings recompile cleanly with both http-telemetry and
  per-call context

Phase-1 follow-ups:
- Helper for HMAC-ing a customer-supplied user_id (currently customer
  computes the HMAC themselves via their stored SOTH_HMAC_KEY)
- Auto-instrumentation that picks up context automatically — that's
  the next commit
- Context introspection helpers (get_current_user_id_hmac, etc.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`soth.instrument()` monkey-patches provider SDK client classes so
customers don't write `soth.guard()` per-call. Built robustly per the
brief — six guarantees gate the contract:

  1. **Idempotent.** instrument() called twice returns
     "skipped:already-instrumented" rather than double-wrapping.
  2. **Reversible.** uninstrument() restores originals captured at
     apply time. If another tool wrapped over our wrapper, we leave
     theirs in place and log rather than clobbering.
  3. **Provider-conditional.** Missing provider packages return
     "skipped:not-installed" without raising — apps deploying with
     subset-of-providers don't break.
  4. **Fail-open.** Any exception in build_call extraction or wrapper
     setup falls back to the original SDK call uninstrumented. The
     customer's API call MUST complete unaffected if SOTH itself fails.
  5. **Version-tolerant.** Adapters use defensive `getattr` /
     `Object.keys` access and walk multiple possible paths into
     provider modules so minor-version SDK changes don't break.
  6. **Sync + async aware.** Each Python adapter wraps both sync
     (`Completions`) and async (`AsyncCompletions`) classes; the
     same `guard()` entry point handles both because it now detects
     coroutine returns.

Python (bindings/soth-py/python/soth/instrumentation/):
  __init__.py: registry + instrument() / uninstrument() /
               is_instrumented(); status dict per provider.
  _base.py:    wrap_method(target, method_name, ...) replaces a
               class method with a SOTH wrapper carrying provenance
               markers (__soth_wrapped__, __soth_provider__,
               __soth_original__). Sync + async wrappers built
               separately. revert_all checks the wrapper is still
               ours before restoring.
  _openai.py:  patches openai.resources.chat.completions.Completions
               + AsyncCompletions + Responses (when available).
               buildCall extracts model/messages/stream/tools/system
               from kwargs; flattens content arrays for
               multi-modal calls. Tool params are stably serialized
               for hash determinism.
  _anthropic.py: patches anthropic.resources.messages.Messages +
               AsyncMessages. Handles Anthropic-specific shape
               (system as separate field, tools with input_schema,
               streaming events with content_block_delta /
               message_delta / message_stop).

guard() update (Python):
  Detects coroutine return from call_fn. Sync providers finalize
  inline; async providers receive a coroutine to await. One
  entry point handles both sync `OpenAI` and async `AsyncOpenAI`
  clients without separate APIs. New `guard_stream_sync()` for
  sync-streaming providers (matches `guard_stream` async generator).

Node (bindings/soth-node/instrumentation/):
  index.js: registry + instrument() / uninstrument() /
            isInstrumented(). Lazy-loaded so `require('@soth/sdk')`
            doesn't pull provider SDKs on import.
  _base.js: wrapMethod walks prototype chain; provenance markers via
            non-enumerable Object.defineProperty so they don't leak
            into JSON.stringify of the wrapped method. revertAll
            same third-party-detection semantic as Python.
  openai.js: walks multiple possible paths to find Completions
             class across openai 4.x sub-versions; falls back
             cleanly when none match.

Negative tests for both bindings:
  - instrument is idempotent (second call: "skipped:already-…")
  - uninstrument reverses
  - uninstrument-without-instrument is safe
  - explicit providers list filters
  - missing provider returns "skipped:not-installed"
  - buildCall exception falls through to original (fail-open)
  - wrapped method carries provenance markers
  - revert leaves third-party wrapper in place
  - guard() handles coroutine and value returns (Python only —
    Node already async-aware)
  - OpenAI/Anthropic adapter apply() returns bool, never raises

What's NOT in this commit (Phase-1.5 follow-ups):
  - Cohere, Google GenAI, Mistral Python adapters
  - Anthropic Node adapter (Anthropic's npm SDK is less mature
    than openai's; will land separately)
  - LangChain / LlamaIndex / Vercel AI SDK callback adapters
    (those go through a different integration path; framework
    adapters are Phase 3 in the original plan)

Verification:
  cargo build --workspace clean
  All workspace tests unchanged (654 + 5 sdk-core integration)
  Conformance harness 7/7 fixtures
  All 4 WASM combos clean

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…adapters

Extends auto-instrumentation to the long-tail providers. Each adapter
follows the existing OpenAI/Anthropic Python pattern (apply + revert +
build_call + chunk_extractor) and inherits the six robustness
guarantees from `_base.py` (idempotent, reversible,
provider-conditional, fail-open, version-tolerant, sync+async aware).

Python — bindings/soth-py/python/soth/instrumentation/:
  _cohere.py:        Cohere v5 ClientV2/AsyncClientV2 (OpenAI-shaped
                     `messages=[{role, content}]`) AND v4 Client
                     (`message=` + `chat_history=`) for legacy users.
                     Streaming chunk extractor handles v5
                     content-delta / message-end events.
  _google_genai.py:  Models.generate_content + generate_content_stream
                     (sync + async). Normalizes the four shapes of
                     `contents` (str / list-of-str / list-of-Content /
                     Content-dict) into a uniform `[{role, content}]`
                     list. system_instruction extracted from `config`.
                     Streaming reads `chunk.text` + finish_reason from
                     candidates[0].
  _mistral.py:       Chat.complete + complete_async + stream + stream_async
                     (mistralai 1.x). OpenAI-equivalent shape so
                     extraction is straightforward; chunk extractor
                     handles Mistral's `data` envelope around OpenAI
                     deltas.

Node — bindings/soth-node/instrumentation/:
  anthropic.js:      Patches Messages.create on @anthropic-ai/sdk.
                     Walks multiple typed-resource path candidates
                     for version tolerance across 0.x. Handles
                     Anthropic-specific shape: separate `system`
                     field, `input_schema` (not `parameters`) on
                     tools, content_block_delta / message_delta /
                     message_stop streaming events.

Registry updates:
  Python REGISTRY now includes openai, anthropic, cohere, google_genai,
  mistralai (5 providers).
  Node REGISTRY now includes openai, anthropic (2 providers).

Tests added:
  - cohere/google_genai/mistral apply() returns bool — no exceptions
  - cohere v2 + v4 extractors normalize messages correctly
  - google_genai handles string vs list-of-Content shapes
  - mistral extractor produces OpenAI-shaped output
  - Anthropic Node apply() + buildCall + chunkExtractor unit tests

Verification:
  cargo build --workspace clean
  All workspace lib tests unchanged (654)
  Conformance harness 7/7 fixtures
  All 4 WASM combos clean
  Adapter tests pass on systems with/without provider SDKs installed
  (apply() returns False when missing; tests skip cleanly)

Phase-1.6 follow-ups (framework adapters — different integration
pattern via callbacks/middleware): LangChain Python, LlamaIndex Python,
LiteLLM Python, Vercel AI SDK Node. Each lands as its own commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ntegrations

Framework integrations layer on top of the existing instrumentation
contract. Where direct provider adapters monkey-patch the SDK class
methods, frameworks use callbacks/middleware instead — SOTH plugs
into each framework's native lifecycle rather than intercepting
underneath it.

Robustness contract is preserved across all four:
  - Module-level imports succeed even when the framework isn't installed
  - Instantiation raises a clear ImportError with the install hint
  - Idempotent registration / un-registration
  - Fail-open: extractor exceptions don't break the customer's chain
  - Async-aware where the framework supports both modes

Python — bindings/soth-py/python/soth/integrations/:

  langchain.py:  SothCallbackHandler — implements both
                 BaseCallbackHandler and AsyncCallbackHandler from
                 langchain-core. Hooks on_llm_start /
                 on_chat_model_start / on_llm_new_token / on_llm_end /
                 on_llm_error onto SOTH's pre/post lifecycle.
                 Per-run state keyed by LangChain's run_id (UUID).
                 Block decisions raise SothBlocked from on_llm_start;
                 LangChain's invoke() propagates the exception up.
                 Provider inferred from `serialized.id` import path
                 (langchain_openai → openai, langchain_anthropic →
                 anthropic, etc.).

  llamaindex.py: SothEventHandler — extends BaseEventHandler from
                 llama_index.core.instrumentation. Hooks the four
                 LLM lifecycle events (LLMChatStartEvent /
                 LLMChatEndEvent / LLMCompletionStartEvent /
                 LLMCompletionEndEvent). Provider inferred from the
                 event class's module path.

  litellm.py:    register() / unregister() / is_registered() — adds
                 success / failure / input handlers to litellm's
                 module-level callback lists. LiteLLM's `model`
                 strings are namespaced ("openai/gpt-4o-mini",
                 "anthropic/claude-3-5-sonnet"); the prefix is parsed
                 for SOTH provider attribution. Per-call state keyed
                 by `litellm_call_id` so concurrent calls don't
                 collide. Block decisions raise SothBlocked from the
                 input handler, aborting the litellm call.

Node — bindings/soth-node/integrations/:

  vercel-ai.js:  sothMiddleware() returns a LanguageModelV1Middleware
                 object compatible with `wrapLanguageModel({ model,
                 middleware })` from the `ai` npm package.
                 wrapGenerate routes non-streaming calls through
                 SothSdk.preCall/postCall; wrapStream taps the
                 LanguageModelV1StreamPart stream via TransformStream
                 to feed text-delta and finish parts to SothSdk's
                 stream observation. Provider inferred from
                 `model.provider` ("openai.chat" → "openai", etc.).

Tests:
  test_integrations.py:
    - module imports succeed without the framework installed
    - LangChain provider inference, message normalization
    - LiteLLM register idempotency (when installed),
      build_call namespace parsing
    - LlamaIndex import safety

  __test__/integrations.test.mjs:
    - sothMiddleware shape matches LanguageModelV1Middleware
    - buildCall normalizes Vercel V1 params (prompt[].content[].text
      → flat string)
    - inferProviderFromModel handles openai/anthropic/google/mistral/cohere
    - middleware pass-through doesn't break customer flow

Verification:
  cargo build --workspace clean
  All workspace tests unchanged (654 + 5 sdk-core integration)
  Conformance harness 7/7 fixtures
  All 4 WASM combos clean

This commit closes out Phase 1.5. Phase 2 (CI matrix + FFI conformance
harness) is the next gating workstream before paying-customer pilots.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the workspace-build verification gap. Until this commit, only
fmt + clippy + cargo test on Ubuntu were gated; the WASM build matrix
and the binding compile-checks ran only on dev laptops. Now every PR
that touches detect/classify/sdk-core or the bindings runs through
the same matrix that paying customers will install from.

.github/workflows/ci.yml — extended:
  + conformance job: cargo test -p soth-conformance-tests --test parity
    (proxy↔SDK direct↔SDK facade lanes, all 7 fixtures)
  + wasm job: 2x2 matrix (target × crate) for the WASM build commit
    Plan 1 PR 1 promised to enforce. Failures here mean an SDK-blocking
    native-dep regression slipped in.
  + bindings-build-check job: cargo build -p soth-py and -p soth-node
    on Linux. Real per-arch wheel/binary builds happen in the
    dedicated workflows.

.github/workflows/python-wheels.yml (new):
  PyO3 + maturin via PyO3/maturin-action@v1. abi3-py310 wheels —
  one wheel per platform×arch covers Python 3.10+, so the matrix
  is 5 jobs (not 5 × N-Python-versions).

  Targets:
    - manylinux2014 x86_64 / aarch64 (aarch64 cross via QEMU)
    - macOS x86_64 (macos-13) / aarch64 (macos-14)
    - Windows x86_64

  Native-arch jobs run a smoke test (`pip install` from local
  --find-links + `import soth`) before uploading the wheel.
  Non-native arches just upload (full smoke runs in
  ffi-conformance.yml downstream).

  Builds an sdist as a fallback for unsupported platforms.

  Triggered on: PR or push to main / sdk-* paths under the binding,
  the sdk-core crate, or the workflow file. Manual dispatch supported.

  Wheels uploaded as per-target artifacts, retention 14 days.
  Publish-to-PyPI is a separate workflow (not in this commit).

.github/workflows/node-binaries.yml (new):
  napi-rs via @napi-rs/cli. Builds prebuilt binaries for the 7
  triples declared in bindings/soth-node/package.json.

  Targets:
    - linux x86_64 (gnu, musl)
    - linux aarch64 (gnu, musl) — gnu via cross-toolchain;
      musl via napi-rs's prebuilt docker image
    - darwin x86_64 / aarch64
    - win32 x86_64-msvc

  Native-arch jobs run a require() smoke test before upload to
  catch obvious linkage errors. Real test runs in
  ffi-conformance.yml.

  Same trigger semantics as python-wheels.yml. Binaries uploaded
  as artifacts, retention 14 days.

What's NOT in this commit:
  - PyPI / npm publish workflows (release-time concern, separate)
  - Source-of-truth version bumping (manual today)
  - Binary signing (when SOTH gets to signed releases)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the fourth lane to the conformance harness: actual PyO3 +
napi-rs FFI. Until this commit, the harness ran proxy / SDK direct /
SDK facade lanes — all in pure Rust. Drift introduced by the
binding's marshalling layer (PyO3 dict construction, napi-rs class
serialization) had no test coverage. This closes that gap.

bindings/soth-py/tests/test_ffi_conformance.py:
  Loads every fixture from
  crates/soth-conformance-tests/fixtures/*.json. For each:
    - Builds the LlmCall dict from typed_call (same conversion the
      Rust SDK lane does in soth-conformance-tests/src/lib.rs).
    - Runs through soth.guard() — crosses the PyO3 boundary.
    - For credential / Block fixtures: asserts SothBlocked raises.
    - For others: asserts the customer call returns and a single
      TelemetryEvent is emitted with provider, model, endpoint_type,
      capture_mode populated.
    - Asserts in_flight_decisions == 0 (slab balanced).
  parametrized over fixtures so each fixture name appears in test
  output; failures point at the diverging field.
  Includes a corpus-floor test that fails if the fixture count
  drops below 7 (Plan 1 PR 5 baseline).

bindings/soth-node/__test__/ffi-conformance.test.mjs:
  Mirror of the Python suite. Uses node:test, async-aware, drives
  through soth.guard() (which crosses napi-rs). Same fixture corpus,
  same assertions (TelemetryEvent shape on the cloud-ingestion
  contract surface).

.github/workflows/ffi-conformance.yml:
  Two jobs, runs on PR / push touching bindings or sdk-core:
    python: maturin develop, then pytest the FFI conformance suite +
            instrumentation + integration + streaming suites
    node:   npm run build:debug, then npm test (which runs every
            *.test.mjs in __test__/, including ffi-conformance)

  Failures here mean either:
    (a) the binding's FFI marshalling drifted from the Rust facade
    (b) the fixture corpus changed in a way the binding hasn't picked
        up yet
  Either way, the failure names the fixture and the diverging field
  so the fix is targeted.

Verification:
  cargo build --workspace clean
  cargo test -p soth-sdk-core: 15 unit + 5 integration
  cargo test -p soth-conformance-tests --test parity: 2/2 (proxy↔SDK,
  SDK↔facade)
  All workspace lib tests unchanged

What this closes vs Plan 2 spec:
  ✓ CI matrix — Rust workspace, Python wheels, Node binaries (prior
    commit)
  ✓ FFI conformance lane — extends the harness through the actual
    PyO3 / napi-rs boundary

Phase 2 is complete. Tier 1 (Python + Node, native, full local
mode) is now functionally shippable: customers can `pip install soth`
on macOS/Linux/Windows, `npm i @soth/sdk` on the same, both with
auto-instrumentation for OpenAI/Anthropic and framework integrations
for LangChain/LlamaIndex/LiteLLM/Vercel AI. Conformance harness
catches drift at four levels (proxy, SDK direct, SDK facade, FFI).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ified)

Closes the Plan 2 phase ledger. Phase 4 in the original plan was
estimated at 4–6 weeks; what lands here is the **scaffold** that
freezes the customer-facing API surface so the next-PR's WASM bridge
work doesn't break anyone who's already integrated.

What this commit DOES deliver (production-ready):

  cargo build -p soth-sdk-core --target wasm32-unknown-unknown
                                 --no-default-features

  is now clean. Required moving soth-classify to default-features=off
  at the workspace level (mirroring soth-detect's existing pattern)
  and explicitly opting back in to onnx-models from soth-bundle and
  soth-proxy. Without this, tokenizers's onig C dep poisons the WASM
  build for any consumer.

bindings/soth-edge/ (new package — @soth/sdk-edge):
  - package.json with `npm run build:wasm` invoking cargo
  - src/index.js — JS shim mirroring @soth/sdk's API
    (init/guard/guardStream/shutdown/SothBlocked) — runs through
    `_invokeWasmStub` placeholder until extern "C" exports land.
    Decision tier matrix locked at module level: Reduced for CF
    Workers / Vercel Edge; Full WASM for Deno / Fastly. No ONNX
    in either path (per WASM trust-boundary spec §3).
  - src/index.d.ts — TypeScript types matching the runtime API
  - README — tier matrix, reduced-mode capability statement,
    CF Workers + Vercel Edge usage examples, the explicit list of
    what's still wired-as-stub

sdks/soth-go/ (new module — github.com/labterminal/soth/sdks/soth-go):
  - go.mod with wazero v1.8.0 dep (no cgo — preserves Go's static-
    binary, alpine, cross-compile story)
  - soth/sdk.go — public Go API: Init / Guard / Shutdown /
    SothBlocked. Mirrors the Python and Node bindings in spirit;
    idiomatic Go (errors.Is / errors.As supported).
  - soth/wasm_bridge.go — wazero bridge with the function signatures
    soth-sdk-core's extern "C" exports will fill in. Today returns
    stubbed Allow decisions so the Go API contract can stabilize
    independently.
  - soth/sdk_test.go — 4 tests gating the public contract:
    * Init requires APIKey + OrgID + Hmac + WasmBytes
    * Init succeeds with full config
    * Guard invokes the customer callback on Allow (stub path)
    * SothBlocked satisfies error / errors.As
    * Shutdown is idempotent
  - README — extensive doc on why wazero (not cgo), the embed pattern
    (`//go:embed soth_sdk_core.wasm`), and the explicit Phase-4
    follow-up list.

docs/common/SDK_PHASE_STATUS.md (new):
  Single-page ledger of Plan 1 / specs / Phase 0 / Phase 1 / Phase 1.5
  / Phase 2 / Phase 3 / Phase 4 status with commit pointers. Distinguishes
  production-ready from scaffold so reviewers and customers see
  exactly what's wired today.

Workspace dep updates:
  Cargo.toml — soth-classify gets default-features=false. Same
  pattern soth-detect already uses. WASM SDK target builds; consumers
  that need onnx-models opt back in.
  crates/soth-bundle/Cargo.toml — explicit onnx-models feature
  crates/soth-proxy/Cargo.toml — explicit onnx-models feature

Verification:
  cargo build --workspace clean
  cargo test -p soth-sdk-core: 15 unit + 5 integration
  cargo test -p soth-conformance-tests --test parity: 2/2
  All 4 WASM combos clean (detect+classify x wasip1+unknown-unknown)
  + soth-sdk-core wasm32-unknown-unknown clean

The SDK build is now dependency-decoupled enough to ship native
Tier-1 bindings (Phase 1), edge scaffold (Phase 4), and Go scaffold
(Phase 4) from one branch. The Phase-4 wasm-bindgen / extern "C"
work is the next dedicated PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Defers SDK-side user-ID hashing to Phase 2.5. In v1, customers
pre-compute userIdHmac themselves and pass it via withContext /
soth.context; the SDK no longer requires hmac_key_env or
hmac_key_static at init.

- soth-sdk-core: hmac_key is Option<HmacKey>; builder no longer
  errors when unset; init/for_test only resolve the key when present
- bindings (PyO3 + napi-rs): (None, None) is now a valid config
  rather than an error; READMEs and docstrings document the
  privacy tradeoff for unkeyed deployments
- New unit test: builder_succeeds_without_hmac_key

Regulated workloads (HIPAA / heavy-PII) SHOULD still configure a
key; without one, anything passed via userIdHmac reaches soth-cloud
as-is. Full key-lifecycle contract: SDK_WASM_TRUST_BOUNDARY_SPEC §6.6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three independent CI bugs surfacing on every push to sdk-build:

1. Node: `napi build --platform` overwrites `index.js` and `index.d.ts`
   wholesale, wiping the hand-written shim (`init`, `guard`,
   `withContext`, `SothBlocked`, …). Tests on Linux were hitting
   `TypeError: soth.init is not a function` because the only thing
   left was a generated loader exporting `SothSdk`. Fix: pass
   `--js binding.js --dts binding.d.ts` so napi-rs writes its
   per-platform loader to a separate file. The shim now does
   `require('./binding.js')` and survives rebuilds. Loader files
   are gitignored (regenerated by CI / dev each build).

2. Python: `maturin develop` requires an active virtualenv and the
   workflow ran it bare, so every push died with
   `Couldn't find a virtualenv or conda environment`. Fix: create a
   `.venv` in `bindings/soth-py`, install pytest+maturin into it,
   and persist `VIRTUAL_ENV` / `PATH` via `$GITHUB_ENV` /
   `$GITHUB_PATH` so subsequent steps reuse the same interpreter.

3. ffi-conformance.test.mjs asserted snake_case keys
   (`endpoint_type`, `capture_mode`) — copy-pasted from the Python
   lane — but napi-rs renders Rust struct fields as camelCase by
   convention, so the binding correctly emits `endpointType` /
   `captureMode`. Fix: assert camelCase in the JS lane; the
   Python lane keeps snake_case.

After this, `npm test` is green locally (32/32) and the Linux CI
runner should resolve `soth-node.linux-x64-gnu.node` via the
generated `binding.js` loader.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`actions/setup-node@v4` with `cache: 'npm'` and
`cache-dependency-path: bindings/soth-node/package-lock.json`
fails with "Some specified paths were not resolved, unable to
cache dependencies" if the lockfile isn't checked in. The previous
commit gitignored it; flip that.

Lockfiles also matter beyond CI: package.json uses caret ranges for
`@napi-rs/cli` and `openai`, so without a lockfile every fresh
install can resolve to slightly different transitive versions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three failures on the node-binaries matrix:

1. aarch64-unknown-linux-gnu: cargo emitted aarch64 object files but
   the host's x86_64 linker tried to link them, producing a wall of
   `is incompatible with elf64-x86-64` errors. Set
   `CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER` and
   `CC_aarch64_unknown_linux_gnu` to `aarch64-linux-gnu-gcc` (already
   apt-installed by the matrix step) so cargo and cc-rs both go
   through the cross toolchain.

2. x86_64-unknown-linux-musl: napi-rs's
   `nodejs-rust:lts-debian-musl` image was removed/renamed —
   `manifest unknown` from the registry. Switch to the canonical
   native-musl image `lts-alpine`; the build runs natively inside
   Alpine so no cross-compile is needed.

3. aarch64-unknown-linux-musl: same dead image, but this one is a
   real cross-compile (x86_64 host → aarch64 target). Switch to
   `lts-debian-zig` and pass `--zig` to `napi build` so it routes
   through `cargo-zigbuild`, which gives the linker a working
   musl-aarch64 sysroot.

Native targets (linux-x64-gnu, darwin-{arm64,x64}, win32-x64-msvc)
already pass and are unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After switching to `lts-alpine` and `lts-debian-zig`, the musl jobs
moved past the previous "manifest unknown" failure but hit a new
one: the napi-rs images ship Rust 1.83, while transitive deps
(time-core 0.1.8, base64ct 1.8.3) require the `edition2024` Cargo
feature stabilized in Rust 1.85.

Add `rustup default stable && rustup update stable` to both musl
build scripts so the in-container toolchain matches the host
(`dtolnay/rust-toolchain@stable`) before `napi build` runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GitHub's macos-13 Intel runner pool is heavily oversubscribed —
the x86_64-apple-darwin job routinely sits queued past the queue
timeout and gets auto-cancelled, blocking the workflow even when
every other matrix entry succeeds.

Move both Mac targets onto macos-14 (Apple Silicon, abundant
runners) and cross-compile to x86_64-apple-darwin via
`rustup target add`. The Xcode SDK on macos-14 ships both arm64
and x86_64 sysroots, so this is a first-class native cross —
no zig, no Rosetta. Drop x86_64-apple-darwin from the smoke-test
step since the arm64 host can't `require()` an x86_64 .node
binary; ffi-conformance.yml is the real load-bearing test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`yum install -y openssl-devel || apt-get update && apt-get install -y libssl-dev pkg-config`
parses as `(yum || apt-get update) && apt-get install ...` due to
left-associative `||`/`&&` precedence. So even when yum succeeded,
apt-get install ran on the CentOS-based manylinux2014 image and
failed with `apt-get: command not found` (exit 127), killing every
Linux wheel build.

manylinux is always yum-based, so the apt fallback is dead code
anyway. Drop it; install pkgconfig from yum directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two regressions on the python-wheels workflow, both surfaced after the
last manylinux fix went in.

aarch64-unknown-linux-gnu was failing at the maturin before-script with
`yum: command not found` (docker exit 127). The previous fix dropped
the apt fallback assuming "manylinux is always yum-based" — which is
true for the native `manylinux2014` image, but the cross image used
for non-native arches is `ghcr.io/rust-cross/manylinux2014-cross:<arch>`,
and that one is Debian-based. Detect the package manager on PATH
instead of assuming.

x86_64-apple-darwin was sitting queued for hours every run on
macos-13 (Intel) — same chronic oversubscription that node-binaries.yml
already worked around in b5f5fa3. Move it to macos-14 (Apple Silicon,
abundant runners) and cross-compile via `rustup target add`. The Xcode
SDK on macos-14 ships both arm64 and x86_64 sysroots, so this is a
first-class native cross. Drop the smoke test on x86_64-apple-darwin
since the arm64 host can't import an x86_64 extension module; PyPI
consumer install on real Intel hardware remains the load-bearing test.

Both stuck runs (25216787926, 25189720745) cancelled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous fix unblocked the apt-get/yum mismatch but exposed the next
layer: ring 0.17.x's pre-generated ARM assembly fails to compile under
the cross-toolchain bundled in `ghcr.io/rust-cross/manylinux2014-cross:aarch64`
because that image's `aarch64-unknown-linux-gnu-gcc` doesn't predefine
`__ARM_ARCH`. cc-rs aborts with `error: #error "ARM assembler must
define __ARM_ARCH"` and maturin exits 1.

Override maturin-action's default image with the native
`quay.io/pypa/manylinux2014_aarch64` (CentOS-based, yum), letting the
QEMU binfmt step we already set up emulate it on the x86_64 host. The
emulated build uses a native aarch64 GCC so ring's assembly compiles
cleanly. Trade ~5 min cross-compile speed for a build that actually
finishes; the workflow as a whole runs once on push, no perf hit
worth chasing here.

Keep the apt-get fallback in before-script-linux as defense in depth
in case the Debian-based image ever sneaks back in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI's `cargo fmt --all -- --check` step on PR #63 was failing because
several files in the SDK crates and bindings drifted out of rustfmt's
canonical layout (mostly long error-builder closures that fmt wants
broken across multiple lines, plus one re-ordered `use` block).
Pure formatting, no behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@secxena
secxena merged commit 88226bb into staging May 2, 2026
39 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant