feat(config): add anthropic_transport_providers allow-list for the empty-sentinel gate - #408
feat(config): add anthropic_transport_providers allow-list for the empty-sentinel gate#408tracycam wants to merge 1 commit into
Conversation
…pty-sentinel gate OpenCode filters empty text/reasoning parts only in the @ai-sdk/anthropic transport branch, so Magic Context gates every empty-sentinel mechanism (cleared reasoning, merged-reasoning strip, thinking-binding recovery, trailing-blank freezes) on providerID === "anthropic". Claude models served through proxy providerIDs (e.g. github-copilot) ride the same signed-block wire — the groupIntoBlocks index-0-thinking rule and signed-block immutability validation apply to them too — yet the gate leaves their reasoning unstripped, reproducing the "thinking blocks cannot be modified" 400 class there. Add a user-tier allow-list: anthropic_transport_providers lists providerIDs whose Claude-named models are served through OpenCode's @ai-sdk/anthropic adapter. The sentinel gate (modelAcceptsEmptyContent) widens to listed providerID + Claude-named model; the resolved model name is plumbed from the main transform through runPostTransformPhase / finalizeMessageRepresentation into the merged-reasoning strip, candidate detection, and thinking-binding recovery lanes. Fail-closed: unlisted providers, missing model names, and non-Claude models under a listed mixed-adapter provider keep native parts. Security: user-tier only — project configs cannot widen the gate (stripped with a warning). Includes schema + regenerated JSON schema/config docs, sentinel and strip-content tests with allow-list reset, project-security strip test.
There was a problem hiding this comment.
🟡 Changes recommended
The main transform still computes canUseEmptySentinels without the resolved model name, which can leave allow-listed Claude proxy providers partially gated while postprocess lanes treat them as eligible.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR expands Magic Context’s “empty-sentinel” gating so Claude models served via proxy providerIDs (that still use OpenCode’s @ai-sdk/anthropic transport) can safely use the same reasoning-strip and related mechanisms that previously only applied to canonical providerID === "anthropic".
Changes:
- Adds a user-tier
anthropic_transport_providersallow-list and threads the resolved model name through post-transform so the empty-sentinel gate can apply to allow-listed providers only for Claude-named models. - Updates merged-reasoning strip, strip-candidate detection, and thinking-binding recovery lanes to use the widened
modelAcceptsEmptyContent(providerID, modelName)predicate. - Adds/updates tests and docs/schema, and strips
anthropic_transport_providersfrom project-tier config for security.
File summaries
| File | Description |
|---|---|
| packages/plugin/src/index.ts | Wires the user config into setAnthropicTransportProviders at plugin boot. |
| packages/plugin/src/hooks/magic-context/transform.ts | Plumbs resolvedModelName into postprocess args (but still computes canUseEmptySentinels earlier without the model). |
| packages/plugin/src/hooks/magic-context/transform-postprocess-phase.ts | Threads resolvedModelName through post-transform/finalization and applies it to empty-sentinel gates and strip candidate detection. |
| packages/plugin/src/hooks/magic-context/strip-content.ts | Uses modelAcceptsEmptyContent(providerID, modelName) for merged-reasoning strip and candidate detection. |
| packages/plugin/src/hooks/magic-context/strip-content.test.ts | Adds tests for allow-listed proxy provider + Claude model behavior and fail-closed behavior. |
| packages/plugin/src/hooks/magic-context/sentinel.ts | Introduces the allow-list state + setAnthropicTransportProviders and widens modelAcceptsEmptyContent to include allow-listed Claude models. |
| packages/plugin/src/hooks/magic-context/sentinel.test.ts | Adds predicate matrix tests (normalization, fail-closed reset, Claude-only behavior). |
| packages/plugin/src/config/schema/magic-context.ts | Adds anthropic_transport_providers to the user config schema with normalization + documentation. |
| packages/plugin/src/config/schema/magic-context.test.ts | Adds a schema parse/normalization test for anthropic_transport_providers. |
| packages/plugin/src/config/project-security.ts | Strips anthropic_transport_providers from project config (user-tier only) with a warning. |
| packages/plugin/src/config/project-security.test.ts | Tests stripping + warning emission for the project-tier key. |
| packages/docs/src/content/docs/reference/configuration.md | Documents the new anthropic_transport_providers setting and security posture. |
| assets/magic-context.schema.json | Regenerates JSON schema to include anthropic_transport_providers. |
Review details
- Files reviewed: 13/13 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| // Pass the single resolved provider + model through to postprocess | ||
| // so every empty-sentinel gate and whole-message placeholder choice | ||
| // agrees for this transform pass, including cold DB-recovered passes. | ||
| // The model name extends the gate to Claude models served under | ||
| // user-configured Anthropic-transport providerIDs. |
There was a problem hiding this comment.
5 issues found across 13 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/plugin/src/hooks/magic-context/sentinel.ts">
<violation number="1" location="packages/plugin/src/hooks/magic-context/sentinel.ts:42">
P2: For an allow-listed Claude transport, the compartment trigger now believes reasoning cannot be cleared because it omits `modelName`. This disables projected reasoning reclamation and can force an unnecessary historian run at the force band even when the post-drop usage would be below target; pass the resolved model name into the trigger capability or compute an explicit capability at the caller.</violation>
<violation number="2" location="packages/plugin/src/hooks/magic-context/sentinel.ts:74">
P3: The fail-closed gate for a listed provider uses a bare `includes("claude")` substring check on the model name. That opens the empty-sentinel gate for any model whose ID merely contains the substring "claude" even when it is not actually a Claude model served through `@ai-sdk/anthropic` — e.g. on a mixed-adapter provider a model ID like "x-claude-compat" would be stripped to empty parts that a non-Anthropic adapter forwards as real content (the 400 "reasoning_content is missing" path the gate is meant to avoid). Every real Claude model ID is a `claude-*`/`claude/*` prefix, so anchoring the match to the start keeps the same intended coverage while staying fail-closed.</violation>
</file>
<file name="packages/plugin/src/hooks/magic-context/transform-postprocess-phase.ts">
<violation number="1" location="packages/plugin/src/hooks/magic-context/transform-postprocess-phase.ts:1187">
P2: When an allow-listed Claude transport is used, this shared gate also enables processed-image stripping even though that path is intentionally canonical-only. Keep a model-aware gate for the reasoning paths, but retain a canonical-only gate for `stripProcessedImages` (and its persisted replay) so this rollout does not silently change image handling.</violation>
</file>
<file name="packages/plugin/src/config/project-security.ts">
<violation number="1" location="packages/plugin/src/config/project-security.ts:322">
P3: The stripUnsafeProjectConfigFields docstring enumerates every stripped project field, but does not mention the newly stripped `anthropic_transport_providers`. Add a bullet so the documented security contract stays in sync with the added strip.</violation>
</file>
<file name="packages/plugin/src/hooks/magic-context/transform.ts">
<violation number="1" location="packages/plugin/src/hooks/magic-context/transform.ts:2259">
P2: The diff propagates the model name only into the postprocess gates, but the main transform's own empty-sentinel gate stays provider-only. `canUseEmptySentinels = modelAcceptsEmptyContent(resolvedProviderID)` (line 1286) still gates `stripStructuralNoise` (1973), `replayClearedReasoning` (1994), and `stripClearedReasoning` (2048), while postprocess now calls `modelAcceptsEmptyContent(args.resolvedProviderID, args.resolvedModelName)`. For an allow-listed non-Anthropic provider serving a Claude model (e.g. github-copilot), the main pass keeps native parts (gate false) but the postprocess strips merged reasoning/cleared reasoning to empty sentinels (gate true). This contradicts the added comment that "every empty-sentinel gate ... agrees for this transform pass" and leaves the allow-list feature only partially applied (structural-noise and cleared-reasoning stripping stay disabled for these providers). Update the main-pass gate to `modelAcceptsEmptyContent(resolvedProviderID, modelForBudget?.modelID)` so both passes agree, or explicitly scope the main-pass strips out in the comment.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| */ | ||
| export function modelAcceptsEmptyContent(providerID?: string, modelName?: string): boolean { | ||
| if (providerID === "anthropic") return true; | ||
| return isClaudeModelUnderConfiguredAnthropicTransport(providerID, modelName); |
There was a problem hiding this comment.
P2: For an allow-listed Claude transport, the compartment trigger now believes reasoning cannot be cleared because it omits modelName. This disables projected reasoning reclamation and can force an unnecessary historian run at the force band even when the post-drop usage would be below target; pass the resolved model name into the trigger capability or compute an explicit capability at the caller.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/plugin/src/hooks/magic-context/sentinel.ts, line 42:
<comment>For an allow-listed Claude transport, the compartment trigger now believes reasoning cannot be cleared because it omits `modelName`. This disables projected reasoning reclamation and can force an unnecessary historian run at the force band even when the post-drop usage would be below target; pass the resolved model name into the trigger capability or compute an explicit capability at the caller.</comment>
<file context>
@@ -31,9 +31,48 @@ export const WHOLE_MESSAGE_PLACEHOLDER_TEXT = "[dropped]";
+ */
+export function modelAcceptsEmptyContent(providerID?: string, modelName?: string): boolean {
+ if (providerID === "anthropic") return true;
+ return isClaudeModelUnderConfiguredAnthropicTransport(providerID, modelName);
+}
+
</file context>
| const canUseEmptySentinels = modelAcceptsEmptyContent(args.resolvedProviderID); | ||
| const canUseEmptySentinels = modelAcceptsEmptyContent( | ||
| args.resolvedProviderID, | ||
| args.resolvedModelName, |
There was a problem hiding this comment.
P2: When an allow-listed Claude transport is used, this shared gate also enables processed-image stripping even though that path is intentionally canonical-only. Keep a model-aware gate for the reasoning paths, but retain a canonical-only gate for stripProcessedImages (and its persisted replay) so this rollout does not silently change image handling.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/plugin/src/hooks/magic-context/transform-postprocess-phase.ts, line 1187:
<comment>When an allow-listed Claude transport is used, this shared gate also enables processed-image stripping even though that path is intentionally canonical-only. Keep a model-aware gate for the reasoning paths, but retain a canonical-only gate for `stripProcessedImages` (and its persisted replay) so this rollout does not silently change image handling.</comment>
<file context>
@@ -1168,7 +1182,10 @@ export async function runPostTransformPhase(
- const canUseEmptySentinels = modelAcceptsEmptyContent(args.resolvedProviderID);
+ const canUseEmptySentinels = modelAcceptsEmptyContent(
+ args.resolvedProviderID,
+ args.resolvedModelName,
+ );
if (shouldRunHeuristics) {
</file context>
| // The model name extends the gate to Claude models served under | ||
| // user-configured Anthropic-transport providerIDs. | ||
| resolvedProviderID, | ||
| resolvedModelName: modelForBudget?.modelID, |
There was a problem hiding this comment.
P2: The diff propagates the model name only into the postprocess gates, but the main transform's own empty-sentinel gate stays provider-only. canUseEmptySentinels = modelAcceptsEmptyContent(resolvedProviderID) (line 1286) still gates stripStructuralNoise (1973), replayClearedReasoning (1994), and stripClearedReasoning (2048), while postprocess now calls modelAcceptsEmptyContent(args.resolvedProviderID, args.resolvedModelName). For an allow-listed non-Anthropic provider serving a Claude model (e.g. github-copilot), the main pass keeps native parts (gate false) but the postprocess strips merged reasoning/cleared reasoning to empty sentinels (gate true). This contradicts the added comment that "every empty-sentinel gate ... agrees for this transform pass" and leaves the allow-list feature only partially applied (structural-noise and cleared-reasoning stripping stay disabled for these providers). Update the main-pass gate to modelAcceptsEmptyContent(resolvedProviderID, modelForBudget?.modelID) so both passes agree, or explicitly scope the main-pass strips out in the comment.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/plugin/src/hooks/magic-context/transform.ts, line 2259:
<comment>The diff propagates the model name only into the postprocess gates, but the main transform's own empty-sentinel gate stays provider-only. `canUseEmptySentinels = modelAcceptsEmptyContent(resolvedProviderID)` (line 1286) still gates `stripStructuralNoise` (1973), `replayClearedReasoning` (1994), and `stripClearedReasoning` (2048), while postprocess now calls `modelAcceptsEmptyContent(args.resolvedProviderID, args.resolvedModelName)`. For an allow-listed non-Anthropic provider serving a Claude model (e.g. github-copilot), the main pass keeps native parts (gate false) but the postprocess strips merged reasoning/cleared reasoning to empty sentinels (gate true). This contradicts the added comment that "every empty-sentinel gate ... agrees for this transform pass" and leaves the allow-list feature only partially applied (structural-noise and cleared-reasoning stripping stay disabled for these providers). Update the main-pass gate to `modelAcceptsEmptyContent(resolvedProviderID, modelForBudget?.modelID)` so both passes agree, or explicitly scope the main-pass strips out in the comment.</comment>
<file context>
@@ -2250,10 +2250,13 @@ export function createTransform(deps: TransformDeps) {
+ // The model name extends the gate to Claude models served under
+ // user-configured Anthropic-transport providerIDs.
resolvedProviderID,
+ resolvedModelName: modelForBudget?.modelID,
thinkingBindingRecoveryEnabledForModel: isFable51ThinkingBindingModel(
modelForBudget?.providerID,
</file context>
|
|
||
| // Widening the empty-sentinel gate is a wire-behavior decision; a cloned | ||
| // repo must not declare that its models filter empty parts. | ||
| if ("anthropic_transport_providers" in projectRaw) { |
There was a problem hiding this comment.
P3: The stripUnsafeProjectConfigFields docstring enumerates every stripped project field, but does not mention the newly stripped anthropic_transport_providers. Add a bullet so the documented security contract stays in sync with the added strip.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/plugin/src/config/project-security.ts, line 322:
<comment>The stripUnsafeProjectConfigFields docstring enumerates every stripped project field, but does not mention the newly stripped `anthropic_transport_providers`. Add a bullet so the documented security contract stays in sync with the added strip.</comment>
<file context>
@@ -317,6 +317,15 @@ export function stripUnsafeProjectConfigFields(projectRaw: Record<string, unknow
+ // Widening the empty-sentinel gate is a wire-behavior decision; a cloned
+ // repo must not declare that its models filter empty parts.
+ if ("anthropic_transport_providers" in projectRaw) {
+ delete projectRaw.anthropic_transport_providers;
+ warnings.push(
</file context>
| if (!providerID || !modelName) return false; | ||
| return ( | ||
| anthropicTransportProviders.has(providerID.toLowerCase()) && | ||
| modelName.toLowerCase().includes("claude") |
There was a problem hiding this comment.
P3: The fail-closed gate for a listed provider uses a bare includes("claude") substring check on the model name. That opens the empty-sentinel gate for any model whose ID merely contains the substring "claude" even when it is not actually a Claude model served through @ai-sdk/anthropic — e.g. on a mixed-adapter provider a model ID like "x-claude-compat" would be stripped to empty parts that a non-Anthropic adapter forwards as real content (the 400 "reasoning_content is missing" path the gate is meant to avoid). Every real Claude model ID is a claude-*/claude/* prefix, so anchoring the match to the start keeps the same intended coverage while staying fail-closed.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/plugin/src/hooks/magic-context/sentinel.ts, line 74:
<comment>The fail-closed gate for a listed provider uses a bare `includes("claude")` substring check on the model name. That opens the empty-sentinel gate for any model whose ID merely contains the substring "claude" even when it is not actually a Claude model served through `@ai-sdk/anthropic` — e.g. on a mixed-adapter provider a model ID like "x-claude-compat" would be stripped to empty parts that a non-Anthropic adapter forwards as real content (the 400 "reasoning_content is missing" path the gate is meant to avoid). Every real Claude model ID is a `claude-*`/`claude/*` prefix, so anchoring the match to the start keeps the same intended coverage while staying fail-closed.</comment>
<file context>
@@ -31,9 +31,48 @@ export const WHOLE_MESSAGE_PLACEHOLDER_TEXT = "[dropped]";
+ if (!providerID || !modelName) return false;
+ return (
+ anthropicTransportProviders.has(providerID.toLowerCase()) &&
+ modelName.toLowerCase().includes("claude")
+ );
}
</file context>
There was a problem hiding this comment.
The transport-focused diagnosis is the right place to look, but the concrete Copilot premise does not hold in OpenCode 1.18.26. The installed github-copilot catalog resolves every model, including Claude, to @ai-sdk/openai-compatible; no Claude entry overrides npm. OpenCode removes empty parts only for model.api.npm === "@ai-sdk/anthropic". Listing github-copilot would therefore put real empty blocks onto an OpenAI-compatible wire. Please do not widen that provider. If custom provider IDs using the Anthropic adapter need support, derive one pass-local capability from the resolved api.npm exposed by provider.list() instead of asking configuration to assert a wire fact.
There is also a blocking split in the current patch. transform.ts still calls modelAcceptsEmptyContent(resolvedProviderID) without the model, while postprocess opens the gate with provider + model. An execute pass can clear old reasoning and persist the watermark, then the next defer skips main-phase replay and restores the native reasoning bytes. That oscillates the cached prefix. The compartment trigger makes the same provider-only decision, while the shared widened postprocess boolean accidentally enables processed-image stripping despite the stated canonical-only scope.
Please replace the split decisions with one adapter-derived capability passed through every phase, add an execute→defer byte-stability regression, keep image/whole-message capabilities explicitly separate, and carry the capability into the Rust transform request (or explicitly scope and track the Rust parity gap). If any model-name fallback remains, anchor it to the actual Claude namespace rather than includes("claude"). The project-tier strip is tested correctly; add anthropic_transport_providers to stripUnsafeProjectConfigFields' enumerated docstring as well.
Problem
OpenCode filters empty text/reasoning parts from the wire only in the
@ai-sdk/anthropictransport branch. Magic Context therefore gates every empty-sentinel mechanism (cleared-reasoning rewrites, merged-reasoning strip, thinking-binding recovery, trailing-blank freezes) behindproviderID === "anthropic".Claude models served through proxy providerIDs (e.g.
github-copilot) ride the same signed-block wire — thegroupIntoBlocksindex-0-thinking rule and the "thinking blocks must remain as they were" immutability validation apply to them identically — yet the canonical-only gate leaves their merged reasoning unstripped, reproducing the 400-class errors the strip machinery exists to prevent.Change
New user-tier setting:
{ "anthropic_transport_providers": ["github-copilot"] }sentinel.modelAcceptsEmptyContent(providerID?, modelName?)widens toanthropic(unchanged) OR listed providerID + Claude-named model; a newsetAnthropicTransportProvidershook is wired at plugin load.modelForBudget) throughrunPostTransformPhase/finalizeMessageRepresentationinto the merged-reasoning strip, the strip-candidate detect pass, and the thinking-binding recovery strip lane.@ai-sdk/anthropic, GPT → openai-compatible) are safe to list without corrupting their non-Claude traffic.Deliberately out of scope (canonical-only until a follow-up): whole-message placeholder emission and processed-image stripping, which don't have the model name plumbed yet and degrade gracefully (non-empty placeholder text is valid wire content everywhere).
Verification
sentinel.test.ts(predicate matrix incl. case-normalization, fail-closed reset).strip-content.test.ts: allow-listed GitHub Copilot + Claude session strips to empty sentinels; non-Claude model and unlisted provider stay untouched; candidate detection mirrors the gate.project-security.test.ts: project-tieranthropic_transport_providersis stripped.Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.Summary by cubic
Adds a user-tier
anthropic_transport_providersallow-list so empty-sentinel reasoning stripping also applies to Claude models served through proxy providers (e.g.github-copilot) that use the same@ai-sdk/anthropicadapter. Previously onlyproviderID === "anthropic"triggered these mechanisms, leaving merged reasoning unstripped on those routes and reproducing the 400-class errors the strip exists to prevent.New Features
@ai-sdk/anthropictransport; the gate widens to listed provider + Claude-named model.Written for commit 80e09fc. Summary will update on new commits.
Greptile Summary
The PR adds a user-scoped allow-list that extends Anthropic-compatible empty-sentinel cleanup to Claude models routed through selected proxy providers.
anthropic_transport_providersconfiguration field.Confidence Score: 5/5
The PR appears safe to merge, with the new transport gate remaining fail-closed for unlisted providers, missing model identities, and non-Claude models.
The resolved provider and model are propagated consistently to the affected cleanup paths, while project configuration is stripped before merging and the new behavior is covered by focused tests.
Important Files Changed
Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart LR U[User configuration] --> N[Normalize provider allow-list] P[Project configuration] --> S[Strip user-only field] N --> G[Empty-sentinel capability gate] S --> R[Resolved plugin configuration] R --> G M[Resolved provider and model] --> G G -->|Canonical Anthropic| C[Apply sentinel cleanup] G -->|Allow-listed provider and Claude model| C G -->|Otherwise| K[Keep native message parts]Reviews (1): Last reviewed commit: "feat(config): add anthropic_transport_pr..." | Re-trigger Greptile
Context used (3)