-
Notifications
You must be signed in to change notification settings - Fork 106
feat(config): add anthropic_transport_providers allow-list for the empty-sentinel gate #408
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| /// <reference types="bun-types" /> | ||
|
|
||
| import { afterEach, describe, expect, it } from "bun:test"; | ||
|
|
||
| import { modelAcceptsEmptyContent, setAnthropicTransportProviders } from "./sentinel"; | ||
|
|
||
| describe("modelAcceptsEmptyContent", () => { | ||
| afterEach(() => { | ||
| setAnthropicTransportProviders([]); | ||
| }); | ||
|
|
||
| it("accepts the canonical anthropic provider regardless of model name", () => { | ||
| expect(modelAcceptsEmptyContent("anthropic")).toBe(true); | ||
| expect(modelAcceptsEmptyContent("anthropic", "claude-opus-4-7")).toBe(true); | ||
| expect(modelAcceptsEmptyContent("anthropic", "some-other-model")).toBe(true); | ||
| }); | ||
|
|
||
| it("rejects unconfigured providers even for Claude-named models", () => { | ||
| expect(modelAcceptsEmptyContent("github-copilot")).toBe(false); | ||
| expect(modelAcceptsEmptyContent("github-copilot", "claude-sonnet-4-6")).toBe(false); | ||
| expect(modelAcceptsEmptyContent(undefined, "claude-sonnet-4-6")).toBe(false); | ||
| expect(modelAcceptsEmptyContent("openrouter")).toBe(false); | ||
| }); | ||
|
|
||
| it("accepts Claude-named models under a configured Anthropic-transport provider", () => { | ||
| setAnthropicTransportProviders(["github-copilot"]); | ||
| expect(modelAcceptsEmptyContent("github-copilot", "claude-sonnet-4-6")).toBe(true); | ||
| expect(modelAcceptsEmptyContent("github-copilot", "CLAUDE-Opus-5")).toBe(true); | ||
| expect(modelAcceptsEmptyContent("GITHUB-COPILOT", "claude-sonnet-4-6")).toBe(true); | ||
| }); | ||
|
|
||
| it("keeps non-Claude models under a configured provider fail-closed", () => { | ||
| // Mixed-adapter providers route non-Claude models through adapters that | ||
| // do NOT filter empty parts; the gate must stay closed for them. | ||
| setAnthropicTransportProviders(["github-copilot"]); | ||
| expect(modelAcceptsEmptyContent("github-copilot", "gpt-5.5")).toBe(false); | ||
| expect(modelAcceptsEmptyContent("github-copilot")).toBe(false); | ||
| expect(modelAcceptsEmptyContent("github-copilot", "")).toBe(false); | ||
| }); | ||
|
|
||
| it("normalizes and validates configured provider entries", () => { | ||
| setAnthropicTransportProviders([" GitHub-Copilot ", "", " "]); | ||
| expect(modelAcceptsEmptyContent("github-copilot", "claude-x")).toBe(true); | ||
| expect(modelAcceptsEmptyContent("", "claude-x")).toBe(false); | ||
| }); | ||
|
|
||
| it("resets to fail-closed when the allow-list is cleared", () => { | ||
| setAnthropicTransportProviders(["github-copilot"]); | ||
| setAnthropicTransportProviders([]); | ||
| expect(modelAcceptsEmptyContent("github-copilot", "claude-x")).toBe(false); | ||
| setAnthropicTransportProviders(["github-copilot"]); | ||
| setAnthropicTransportProviders(undefined); | ||
| expect(modelAcceptsEmptyContent("github-copilot", "claude-x")).toBe(false); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -31,9 +31,48 @@ export const WHOLE_MESSAGE_PLACEHOLDER_TEXT = "[dropped]"; | |
| * | ||
| * Unknown or non-canonical providers therefore must keep native parts (or use | ||
| * non-empty whole-message placeholders) rather than producing empty sentinels. | ||
| * | ||
| * One user-asserted exception exists: providerIDs listed in the user-tier | ||
| * `anthropic_transport_providers` setting serve Claude-named models through | ||
| * OpenCode's `@ai-sdk/anthropic` adapter as well, so the wire filter drops | ||
| * empty sentinels for them too (see isClaudeModelUnderConfiguredAnthropicTransport). | ||
| */ | ||
| export function modelAcceptsEmptyContent(providerID?: string, modelName?: string): boolean { | ||
| if (providerID === "anthropic") return true; | ||
| return isClaudeModelUnderConfiguredAnthropicTransport(providerID, modelName); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: For an allow-listed Claude transport, the compartment trigger now believes reasoning cannot be cleared because it omits Prompt for AI agents |
||
| } | ||
|
|
||
| let anthropicTransportProviders: ReadonlySet<string> = new Set(); | ||
|
|
||
| /** | ||
| * Populate the Anthropic-transport allow-list from the user-tier setting | ||
| * `anthropic_transport_providers` (see config entry point). Only providers | ||
| * whose Claude models ride OpenCode's `@ai-sdk/anthropic` adapter belong here: | ||
| * that adapter filters empty text/reasoning parts before the wire. A provider | ||
| * that mixes adapters (Claude models on @ai-sdk/anthropic, other models on an | ||
| * openai-compatible adapter) is safe to list because the gate additionally | ||
| * requires a Claude-named model. Fail-closed: unlisted providers, missing | ||
| * model names, and non-Claude model names keep native parts. | ||
| */ | ||
| export function modelAcceptsEmptyContent(providerID?: string): boolean { | ||
| return providerID === "anthropic"; | ||
| export function setAnthropicTransportProviders(providerIDs?: Iterable<string>): void { | ||
| const normalized = new Set<string>(); | ||
| for (const id of providerIDs ?? []) { | ||
| if (typeof id === "string" && id.trim().length > 0) { | ||
| normalized.add(id.trim().toLowerCase()); | ||
| } | ||
| } | ||
| anthropicTransportProviders = normalized; | ||
| } | ||
|
|
||
| function isClaudeModelUnderConfiguredAnthropicTransport( | ||
| providerID?: string, | ||
| modelName?: string, | ||
| ): boolean { | ||
| if (!providerID || !modelName) return false; | ||
| return ( | ||
| anthropicTransportProviders.has(providerID.toLowerCase()) && | ||
| modelName.toLowerCase().includes("claude") | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: The fail-closed gate for a listed provider uses a bare Prompt for AI agents |
||
| ); | ||
| } | ||
|
|
||
| /** | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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