Skip to content

fix: detect Anthropic wire from the model adapter - #434

Open
smithjw wants to merge 3 commits into
cortexkit:masterfrom
smithjw:ai/fix/anthropic-wire-thinking-blocks
Open

fix: detect Anthropic wire from the model adapter#434
smithjw wants to merge 3 commits into
cortexkit:masterfrom
smithjw:ai/fix/anthropic-wire-thinking-blocks

Conversation

@smithjw

@smithjw smithjw commented Sep 11, 2026

Copy link
Copy Markdown

Summary

Magic Context used providerID === "anthropic" to decide whether it could use empty-part sentinels. OpenCode makes that decision from the resolved model adapter instead:

model.api.npm === "@ai-sdk/anthropic"

This matters for custom providers. A Portkey gateway can use the Anthropic adapter under a provider id such as portkey. The gateway receives Anthropic-format messages, but Magic Context previously skipped the merged-reasoning strip. Anthropic can then reject a long session because a thinking block in the latest assistant message changed.

This change reads each model's resolved adapter from provider.list(). It enables the sentinel paths only for models on @ai-sdk/anthropic, including custom provider ids. The lookup is per model because a provider can mix adapters across its models.

Cache safety

  • One capability is resolved for the whole transform pass and passed to every sentinel consumer
  • A new custom-provider capability folds the m[0] baseline once before it changes served bytes
  • An unresolved registry carries forward the prior answer only for the same model
  • Provider-list lookups have a five-second limit and retry after a cooldown

Rust mode remains limited to the canonical anthropic provider. Its module applies its own provider-id checks. Pi does not use these OpenCode paths. Both limits are documented in A55 of docs/AUDIT-KNOWN-ISSUES.md.

Validation

  • Verified a live opencode serve reports the custom Portkey Claude models with api.npm: "@ai-sdk/anthropic"
  • Verified the shipped OpenCode binary filters empty parts in the @ai-sdk/anthropic adapter branch
  • Added unit coverage for custom providers, mixed adapters, resolver states, execute-to-defer byte stability, and the one-time m[0] fold
  • bun run typecheck, plugin lint, build, and the thinking-block e2e test pass
  • packages/pi-plugin tests: 1027 pass
  • packages/plugin tests: 4644 pass; 2 existing dreamer/verify tests fail because temporary test repositories inherit SSH signing without a key

I also ran the full e2e suite. It reports 9 failures. Each reproduces on unmodified master.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

Fixes Magic Context's empty-part sentinel gating so custom providers that use the @ai-sdk/anthropic adapter (e.g., Bedrock/Vertex gateways) no longer send thinking blocks Anthropic rejects. The gate now reads each model's resolved adapter from provider.list() instead of using providerID === "anthropic".

Bug Fixes

  • Resolves the capability per model because a provider can mix adapters across its models.
  • Normalizes persisted sentinels when the model's resolved capability changes, so stale empty parts or placeholders don't survive a switch.
  • Memoizes successful registry loads and retries after a cooldown, with a five-second limit.
  • Records the widened capability in the m[0] upgrade identity so it folds exactly once, preserving byte stability.
  • Keeps Rust mode limited to the canonical anthropic provider and documents the limitation in A55.

Written for commit 39dab68. Summary will update on new commits.

Review in cubic

Greptile Summary

This PR resolves empty-sentinel support from each model’s OpenCode adapter rather than its provider ID, allowing custom providers using @ai-sdk/anthropic to receive Anthropic-safe reasoning and placeholder transformations.

  • Adds a process-level, retryable provider-registry lookup with per-model adapter detection.
  • Carries the resolved capability consistently through stripping, compartment injection, and postprocessing.
  • Records custom-provider widening in the m[0] upgrade identity to materialize changed wire bytes once.
  • Normalizes persisted whole-message sentinels when the active model’s wire capability changes.
  • Adds coverage for custom and mixed-adapter providers, unresolved registry states, replay stability, and model transitions.
  • Documents the intentional canonical-provider limitation in Rust mode.

Confidence Score: 5/5

The PR appears safe to merge; the latest changes correctly normalize persisted sentinels across model capabilities without bypassing the existing materialization gates.

No actionable new defects or outstanding previous findings were established. Model recovery seeds the shared live-model map before hard-signal evaluation, keeping adapter capability resolution, model-change detection, and m[0] folding aligned.

Important Files Changed

Filename Overview
packages/plugin/src/hooks/magic-context/anthropic-wire.ts Adds bounded, retryable per-model detection of OpenCode’s Anthropic adapter and stable unresolved-state handling.
packages/plugin/src/hooks/magic-context/transform.ts Resolves the wire capability once per transform pass and propagates it through all relevant phases.
packages/plugin/src/hooks/magic-context/inject-compartments.ts Folds custom-provider capability changes into m[0] only when the live model is known and unchanged.
packages/plugin/src/hooks/magic-context/sentinel.ts Replays persisted sentinels in the representation required by the currently resolved wire capability.
packages/plugin/src/hooks/magic-context/transform-postprocess-phase.ts Uses the pass-level capability for replay and detection while preserving cache-busting boundaries.
packages/plugin/src/hooks/magic-context/compartment-render-epoch.ts Extends the persisted upgrade identity with the custom-provider Anthropic-wire widening state.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Transform pass starts] --> B[Resolve live model]
    B --> C[Load provider registry]
    C --> D{api.npm is @ai-sdk/anthropic?}
    D -->|Canonical or matching custom model| E[Enable empty sentinels]
    D -->|Other adapter| F[Use dropped placeholder]
    C -->|Temporarily unresolved| G{Same cached model?}
    G -->|Yes| H[Carry prior widened capability]
    G -->|No| F
    E --> I[Compare m0 adapter identity]
    F --> I
    H --> I
    I --> J{Provider-visible capability changed?}
    J -->|Yes, same known model| K[Materialize and persist new baseline]
    J -->|No| L[Replay frozen decisions]
    K --> L
    L --> M[Strip reasoning and normalize sentinels consistently]
Loading

Reviews (2): Last reviewed commit: "fix: normalize sentinels after model cha..." | Re-trigger Greptile

OpenCode filters empty text/reasoning parts off the wire in exactly one
branch, gated on `model.api.npm === "@ai-sdk/anthropic"`. Magic Context
gated the machinery that depends on that filter — the merged-reasoning
strip, cleared-reasoning strip, structural-noise strip, whole-message
placeholders — on `providerID === "anthropic"` instead.

A custom provider declaring `npm: "@ai-sdk/anthropic"` (a Bedrock or
Vertex gateway under its own id) therefore rode the Anthropic wire with
none of the workarounds it needs, and Anthropic rejected the request:
"`thinking` or `redacted_thinking` blocks in the latest assistant message
cannot be modified." One provider can also serve different models through
different adapters, so the answer has to be per model, not per provider.

`anthropic-wire.ts` reads the resolved adapter of every configured model
once per process from `client.provider.list()`, and
`resolveEmptySentinelCapability` turns that into one boolean for the pass.
Every consumer now takes that boolean instead of a provider string, so no
two phases of a pass can disagree and oscillate the cached prefix.

Cache safety:

- An unresolved model or registry is NOT read as "not widened". It carries
  forward what the session last materialized under, keyed to the model.
  Narrowing would stop replaying merged-reasoning strips that are already
  persisted and put signed thinking back on the wire.
- A real change is recorded in the m[0] upgrade identity, so it folds
  exactly once instead of rewriting the tail on a pass that promised
  byte-identical replay. Scoped to a known, unchanged model: an unknown
  signal never folds, and a changed model already folds via `model_change`.
- A failed lookup is not memoised. It retries after a cooldown, bounded by
  a 5s deadline so a hung local request cannot stall the transform.

Rust mode stays canonical-Anthropic only, because the module keeps its own
provider-id gates and both lanes must agree on the served bytes. Pi is
unaffected. Both are tracked in docs/AUDIT-KNOWN-ISSUES.md A55 with the
full state table.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 20 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/plugin/src/config/latch-permanence-guard.test.ts Outdated
@blairhudson

Copy link
Copy Markdown

Would be great to see this fixed

@ualtinok

Copy link
Copy Markdown
Contributor

Note for @alfonso-magic-context

There are providers/models using @ai-sdk/anthropic but are not actually anthropic models. (model.api.npm === "@ai-sdk/anthropic" may be wrong for those and we may need to check model itself)

@magic-alfonso

magic-alfonso Bot commented Sep 11, 2026

Copy link
Copy Markdown

Six-axis review on 39dab689, executed rather than read. The premise holds at OpenCode source and on a live server: custom providers on the Anthropic adapter were shipping thinking blocks the adapter rejects.

Verified good.

  • A canonical anthropic/* session's m[0] is byte-identical on this branch — no upgrade fold for existing users.
  • A custom-provider session on the Anthropic adapter folds exactly once and then replays identically.
  • Persisted sentinel normalization on adapter switch (your 39dab689): the executed A→B→A arm renders "" on the Anthropic adapter, [dropped] on a non-Anthropic one, "" after switching back; each state is byte-stable across two defer passes, and both rewrites ride the already-priced model_change HARD, not a later unpriced defer.
  • The latch guard comment now names the real resolver. Protected region untouched; typecheck, both full suites and Biome exit 0.

Key shape — per @ualtinok's note above. Providers exist that speak @ai-sdk/anthropic but serve non-Anthropic models, so the resolved adapter is the right key for exactly one of the two gates:

  • Empty-content sentinel (which placeholder an empty part becomes): keyed on the adapter — that is a wire-shape property and your change is right there.
  • Merged-assistant reasoning strip and the newest-assistant reasoning exemption: these exist for Anthropic's signed thinking blocks. A non-Anthropic model behind the Anthropic-compatible API has no signed thinking, so keying them on the adapter alone would strip or preserve the wrong blocks. Please key those on adapter and canonical model identity (the same family match variantChangeBustsProviderCache uses in sentinel.ts), with a test for the "Anthropic adapter, non-Anthropic model" cell.

One blocker. transform.ts:1313-1317 awaits client.provider.list() inside the transform, before scheduler classification. With a hung provider the executed pass took 5,016 ms before reaching its execute decision, and the result is memoized without an invalidation event (60 s cooldown poll on failure). Our hot path admits no awaited I/O of this kind: resolve the adapter map at boot and on provider change events, and have the pass consume an already-resolved snapshot without waiting (an unknown provider falls back to the current provider-id rule for that pass, never blocks).

Wording. A55 overstates parity: Pi passes only providerId to the capability resolver for two strip paths, so it shares the custom-provider gap rather than being unaffected; and the Rust module stays provider-keyed with no adapter on the wire. Say both plainly so the follow-ups are visible.

With the two-key split and the await moved out of the pass this is mergeable.

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.

3 participants