Skip to content

fix(proxy): apply exclusions and real provider identity to summarizer calls - #884

Open
devin-ai-integration[bot] wants to merge 2 commits into
mainfrom
devin/1786112570-summarizer-egress-policy
Open

fix(proxy): apply exclusions and real provider identity to summarizer calls#884
devin-ai-integration[bot] wants to merge 2 commits into
mainfrom
devin/1786112570-summarizer-egress-policy

Conversation

@devin-ai-integration

Copy link
Copy Markdown
Contributor

Summary

The handover and compaction summarizers send the entire prior conversation upstream — on every model switch and every compaction turn — and neither call consulted the exclusion lists that #883 made authoritative for routed traffic. An installation that excluded a provider still had full conversations shipped to it, through the one call that carries the most content.

Worse, ProviderSummarizer.Provider() returned anthropic regardless of ROUTER_HANDOVER_PROVIDER, and that string is what credential resolution keys off. Point handover at a Bearer gateway and the tenant's Anthropic key gets resolved and handed to the gateway client, which sends it as Authorization: Bearer to the gateway's URL:

// before
func (s *ProviderSummarizer) Provider() string { return providers.ProviderAnthropic }

Both summarizer paths now go through one gate:

func (s *Service) gateSummarizerCall(ctx, provider, model string, headers http.Header) summarizerGate {
    if excluded(s.policyExcludedProviders(ctx), provider) { return skip(providerExcluded) }
    if excluded(s.excludedModelsForRequest(ctx), model)   { return skip(modelExcluded) }
    creds := resolveSummarizerCreds(ctx, provider, headers)   // unchanged tenant boundary
    if creds == nil && s.requestUsesNonDeploymentCreds(...)   { return skip(tenantBoundary) }
    return summarizerGate{Creds: creds, Allowed: true}
}

A refusal takes the fallback that already exists for a summarizer error — full history on switch, trim on compaction — so no request fails because of this.

Three things worth calling out:

  • Exclusions are checked before credential resolution. Forwarding your own BYOK key doesn't buy egress to a provider the operator excluded.
  • The gate reads policyExcludedProviders, not excludedProvidersForRequest. The latter folds in transient 529 session strike-outs; a strike is evidence, not policy, and shouldn't silently disable summarization for the rest of a session.
  • Compaction's window-aware model choice skips excluded models (selectCompactionSummarizer now takes a ctx), rather than picking a banned model and having the gate refuse it.

handover.Summarizer grows Model() alongside Provider() so the orchestrator can ask what will actually be called. main.go additionally refuses to wire a handover provider that isn't in FamilyAnthropic — the summarizer emits Anthropic Messages bodies with no translator in front of it, so a non-Anthropic-format client was never going to work; it now logs and leaves summarization off instead.

Tests: the end-to-end one (TestTurnLoop_HandoverSkippedWhenSummarizerProviderExcluded) was verified to fail against the pre-fix code — with the provider check removed it invokes the summarizer and reports handover_fallback_to_full_history: false.

Link to Devin session: https://app.devin.ai/sessions/4f53046daf8d4dbd8ac1c5695709f00b
Requested by: @steventohme

… calls

The handover and compaction summarizers send the entire prior conversation upstream, and did so without consulting the exclusion lists that are authoritative for routed traffic: an installation that excluded a provider still had full conversations shipped to it.

ProviderSummarizer also reported anthropic regardless of ROUTER_HANDOVER_PROVIDER, so credential resolution looked up the tenant's Anthropic key and handed it to whatever client was configured - a Bearer gateway would have received it as Authorization: Bearer.

Both summarizer paths now go through gateSummarizerCall, which refuses an excluded provider or model before resolving credentials and keeps the existing tenant-boundary rule. A refusal takes the same fallback as a summarizer error: full history on switch, trim on compaction.
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@greptile-apps

greptile-apps Bot commented Aug 7, 2026

Copy link
Copy Markdown

PR author is not in the allowed authors list.

@claude

claude Bot commented Aug 7, 2026

Copy link
Copy Markdown

Claude finished @devin-ai-integration[bot]'s task —— View job


Comment-length lint

  • Gather diff
  • Analyze added comment blocks
  • Post review

Posted 3 comment-length nits as committable suggestions on the PR review. No logic/correctness comments.

@workweave-bot workweave-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Advisory only — comment-length nits. Won't block merge.

Comment thread internal/proxy/summarizer_policy.go Outdated
Comment on lines +28 to +39
// gateSummarizerCall decides whether the summary of a conversation may be sent
// to provider/model for this request.
//
// A summary call ships the entire prior conversation upstream, so it is subject
// to the same exclusions as routed traffic: a provider or model the operator
// excluded must not receive the content it was excluded from seeing. Session
// strike-outs are deliberately not consulted — transient 529 evidence is not a
// policy statement, matching policyExcludedProviders.
//
// The tenant boundary is unchanged: prefer the caller's own forwarded
// credentials, and rather than spend the deployment key on a BYOK/client-keyed
// request, skip.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
// gateSummarizerCall decides whether the summary of a conversation may be sent
// to provider/model for this request.
//
// A summary call ships the entire prior conversation upstream, so it is subject
// to the same exclusions as routed traffic: a provider or model the operator
// excluded must not receive the content it was excluded from seeing. Session
// strike-outs are deliberately not consulted — transient 529 evidence is not a
// policy statement, matching policyExcludedProviders.
//
// The tenant boundary is unchanged: prefer the caller's own forwarded
// credentials, and rather than spend the deployment key on a BYOK/client-keyed
// request, skip.
// gateSummarizerCall decides whether a summary call may be dispatched to provider/model.
// Summaries ship the full prior conversation, so policy exclusions apply — use
// policyExcludedProviders (not session strike-outs, which are transient evidence).
// On BYOK/client-keyed requests without matching forwarded creds, skip to avoid
// spending the deployment key across the tenant boundary.

Was 9 lines across 3 paragraphs; the three WHYs fit in 5.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied verbatim in bca79e7.

Comment thread internal/proxy/summarizer_policy.go Outdated
Comment on lines +56 to +60
// summarizerContext returns the context the summary call runs under: the
// caller's own credentials when the gate resolved some, otherwise a context
// stripped of any request credential (e.g. a subscription OAuth token) so the
// synthetic call runs on the deployment key instead of inheriting one that
// would 401 or cross a tenant boundary.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
// summarizerContext returns the context the summary call runs under: the
// caller's own credentials when the gate resolved some, otherwise a context
// stripped of any request credential (e.g. a subscription OAuth token) so the
// synthetic call runs on the deployment key instead of inheriting one that
// would 401 or cross a tenant boundary.
// summarizerContext returns the context for the summary call: caller creds when
// available, otherwise stripped so a subscription OAuth token can't 401 or cross a
// tenant boundary.

Was 5 lines; 3 carry the same information.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied verbatim in bca79e7.

Comment thread internal/proxy/turnloop_test.go Outdated
Comment on lines +759 to +762
// A summary carries the entire prior conversation, so an installation that
// excluded the summarizer's provider must not have it sent there — even though
// the request itself routes to a permitted provider and the deployment holds a
// key for the excluded one.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
// A summary carries the entire prior conversation, so an installation that
// excluded the summarizer's provider must not have it sent there — even though
// the request itself routes to a permitted provider and the deployment holds a
// key for the excluded one.
// An excluded summarizer provider must not receive the conversation even when
// the routed model is permitted and the deployment holds a key for it.

Was 4 lines; the "even though" qualifiers reduce to one clause.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied verbatim in bca79e7.

@claude

claude Bot commented Aug 7, 2026

Copy link
Copy Markdown

Claude finished @devin-ai-integration[bot]'s task —— View job


Comment-length lint

  • Gather diff
  • Analyze added comment blocks
  • Post review

No comment blocks warrant flagging after the previous nits were applied.

View job

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

Runtime verification — summarizer egress policy ✅

Tested bca79e7 end-to-end against a local docker-compose router with two recording fake upstreams (Anthropic-shaped :8099, OpenAI-shaped :8097) that log every upstream request to JSONL. No real vendor endpoint or credential was contacted; api.anthropic.com was pinned to 127.0.0.1 in-container so an accidental fallback would fail loudly on-box. Runtime only — unit tests/make check out of scope.

Harness note — why this is adversarial rather than tautological: ROUTER_HANDOVER_PROVIDER is set to anthropic_gateway (registered, FamilyAnthropic, pointed at an observable fake) and that is the excluded name. Under the pre-PR hardcoded Provider() == "anthropic", the gate would compare anthropic against {anthropic_gateway}, not fire, and the fake would still record the summary. The fake's request count therefore catches the regression even if the log line looks right.

The fix: exclusion refuses the summary, and the turn still succeeds
$ ROUTER_EXCLUDED_PROVIDERS=anthropic_gateway

turn2_http=200
turn2_client_text=OA-FAKE-REPLY from the fake openai upstream.

"message":"Handover summarizer skipped by policy; preserved full history instead",
  "skip_reason":"provider_excluded","pin_model":"gpt-5.4-mini","fresh_model":"gpt-5.5",
  "sum_provider":"anthropic_gateway"

"message":"router switched models","handover_invoked":true,
  "handover_fallback_to_full_history":true

gateway_requests=0 SUMMARY_requests=0 openai_requests=2
  [OPENAI/MAIN] path=/v1/responses model=gpt-5.4-mini n_messages=1
  [OPENAI/MAIN] path=/v1/responses model=gpt-5.5      n_messages=9

sum_provider is the real configured provider, zero requests reached the excluded gateway, the client got a normal 200, and the switched request carried the full 9-message history (vs 2 in the summarized baseline, where handover rewrites history to summary + latest turn).

Same call site, other refusal reasons: ROUTER_EXCLUDED_MODELS=claude-haiku-4-5skip_reason=model_excluded; an inbound cross-family Authorization: Bearerskip_reason=tenant_boundary (and the caller's own key appeared 0 times in both upstream JSONLs).

Baseline unchanged (the control that makes the above meaningful)
"message":"router switched models","from":"gpt-5.4-mini","to":"claude-opus-5",
  "reason":"tier_upgrade","handover_invoked":true,
  "handover_fallback_to_full_history":false,"handover_latency_ms":1

gateway_requests=2 SUMMARY_requests=1 openai_requests=1
  [GATEWAY/SUMMARY] path=/v1/messages model=claude-haiku-4-5 n_messages=10 max_tokens=800 auth=Bearer gw-DEPLOY-…
  [GATEWAY/MAIN]    path=/v1/messages model=claude-opus-5    n_messages=2
Compaction paths — both call sites, each with a negative control

Reaching Tier 3 needs care: maybeCompact returns early if the post-Tier-1 history fits the largest eligible model, and logs "no permitted summarizer window fits" if it's too big for the summarizer. A 3.86 MB / 967-message body with max_tokens=100000 lands the estimate in the gap (over gpt-5.5's 1.05 M window, under claude-fable-5's 1 M).

Gated:

"Compaction cascade engaged","needed":1076335,"trigger":892500,"max_window":1050000
"Compaction Tier-3 skipped by policy","skip_reason":"tenant_boundary",
  "sum_provider":"anthropic_gateway","summary_model":"claude-fable-5"
"Compaction rescue: trimmed to recent turns","kept_recent":12
"Proactive compaction applied","summarized":false,"trimmed_to_recent":12
http=200        gateway requests: 0

Negative control — identical request, gate condition removed:

"Compaction Tier-3: history summarized","summary_model":"claude-fable-5"
gateway requests: 1
  path=/v1/messages model=claude-fable-5 n_messages=968 body_kb=3814
  "The conversation is being compacted to fit the model's context window. Produce a
   detailed structured summary of everything above under these numbered sections..."

968 messages / 3.8 MB is what the permitted path ships upstream — precisely the exposure this PR gates. ROUTER_EXCLUDED_PROVIDERS on the same turn also yields skip_reason=provider_excluded, 200, 0 requests.

The third call site (runCompactionHandover, reached via client prefix-trim + planner STAY on a non-Anthropic route) behaves the same: permitted → "context rewritten with summary"; gated → "Compaction handover: summarizer skipped by policy; preserved compacted body instead", 200, 0 requests.

Boot behavior
ROUTER_HANDOVER_PROVIDER=openai
{"severity":"WARNING","message":"Handover summarizer disabled (provider does not speak the
 Anthropic Messages format); switch turns will preserve full history instead",
 "requested_provider":"openai"}
$ grep -c "Handover summarizer wired"  ->  0     /health -> {"status":"ok"}

A switch turn under that config still returns 200 and logs "Handover summarizer not wired; preserved full history instead" — summarization is genuinely off, not silently emitting Anthropic bodies at OpenAI.

ROUTER_HANDOVER_PROVIDER=not_a_provider"Handover summarizer disabled (provider not registered)", healthy, no panic.

One non-defect note for reviewers: message_count in "upstream prepared request" is a pre-handover snapshot (feats is never recomputed after the envelope rewrite), so it reads 9 whether or not the summary was applied. Use body_len or the upstream's actual message count to distinguish. Pre-existing, not introduced here.

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.

2 participants