feat: add Anthropic 1M context window toggle for Agents model configs (#27257)

This commit is contained in:
Michael Suchacz
2026-07-17 13:04:13 +02:00
committed by GitHub
parent 2ac2295b1e
commit 1ac106255b
12 changed files with 327 additions and 29 deletions
+25 -20
View File
@@ -338,10 +338,11 @@ func (p *Server) resolveAdvisorModelOverride(
return fallbackModel, fallbackCallConfig, nil
}
overrideModel, err := p.newModel(ctx, modelClientRequest{
Chat: chat,
ModelName: overrideConfig.Model,
UserAgent: chatprovider.UserAgent(),
ExtraHeaders: chatprovider.CoderHeaders(chat),
Chat: chat,
ModelName: overrideConfig.Model,
UserAgent: chatprovider.UserAgent(),
ExtraHeaders: chatprovider.CoderHeaders(chat),
ConfigOptions: overrideConfig.Options,
}, route, modelOpts)
if err != nil {
if overrideConfig.AIProviderID.Valid {
@@ -2397,10 +2398,11 @@ func (p *Server) prepareManualTitleDebugRun(
debugModelErr = routeErr
} else {
debugModel, debugModelErr = p.newModel(ctx, modelClientRequest{
Chat: chat,
ModelName: modelConfig.Model,
UserAgent: chatprovider.UserAgent(),
ExtraHeaders: chatprovider.CoderHeaders(chat),
Chat: chat,
ModelName: modelConfig.Model,
UserAgent: chatprovider.UserAgent(),
ExtraHeaders: chatprovider.CoderHeaders(chat),
ConfigOptions: modelConfig.Options,
}, route, debugOpts)
}
switch {
@@ -2591,10 +2593,11 @@ func (p *Server) resolveManualTitleModel(
return p.resolveFallbackManualTitleModel(ctx, chat, modelOpts)
}
model, err := p.newModel(ctx, modelClientRequest{
Chat: chat,
ModelName: config.Model,
UserAgent: chatprovider.UserAgent(),
ExtraHeaders: chatprovider.CoderHeaders(chat),
Chat: chat,
ModelName: config.Model,
UserAgent: chatprovider.UserAgent(),
ExtraHeaders: chatprovider.CoderHeaders(chat),
ConfigOptions: config.Options,
}, route, modelOpts)
if err != nil {
p.logger.Debug(ctx, "manual title preferred model unavailable",
@@ -2625,10 +2628,11 @@ func (p *Server) resolveFallbackManualTitleModel(
return nil, database.ChatModelConfig{}, err
}
model, err := p.newModel(ctx, modelClientRequest{
Chat: chat,
ModelName: config.Model,
UserAgent: chatprovider.UserAgent(),
ExtraHeaders: chatprovider.CoderHeaders(chat),
Chat: chat,
ModelName: config.Model,
UserAgent: chatprovider.UserAgent(),
ExtraHeaders: chatprovider.CoderHeaders(chat),
ConfigOptions: config.Options,
}, route, modelOpts)
if err != nil {
return nil, database.ChatModelConfig{}, xerrors.Errorf(
@@ -3913,10 +3917,11 @@ func (p *Server) resolveChatModel(
}
model, debugEnabled, err = p.newDebugAwareModel(ctx, modelClientRequest{
Chat: chat,
ModelName: dbConfig.Model,
UserAgent: chatprovider.UserAgent(),
ExtraHeaders: chatprovider.CoderHeaders(chat),
Chat: chat,
ModelName: dbConfig.Model,
UserAgent: chatprovider.UserAgent(),
ExtraHeaders: chatprovider.CoderHeaders(chat),
ConfigOptions: dbConfig.Options,
}, route, modelOpts)
if err != nil {
return nil, database.ChatModelConfig{}, aiGatewayModelRoute{}, false, "", "", xerrors.Errorf(
+57
View File
@@ -53,6 +53,7 @@ import (
"github.com/coder/coder/v2/coderd/x/chatd/chatadvisor"
"github.com/coder/coder/v2/coderd/x/chatd/chatdebug"
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
"github.com/coder/coder/v2/coderd/x/chatd/chatprovider"
"github.com/coder/coder/v2/coderd/x/chatd/chatsanitize"
"github.com/coder/coder/v2/coderd/x/chatd/chatstate"
"github.com/coder/coder/v2/coderd/x/chatd/chattest"
@@ -7118,6 +7119,62 @@ func TestActiveServer_AnthropicSanitizesProviderToolBeforeRequest(t *testing.T)
require.Contains(t, body, "redacted-payload")
}
func TestActiveServer_AnthropicContext1MBetaHeader(t *testing.T) {
t.Parallel()
runChat := func(t *testing.T, callConfig *codersdk.ChatModelCallConfig) []chattest.AnthropicRequest {
t.Helper()
ctx := testutil.Context(t, testutil.WaitLong)
db, ps := dbtestutil.NewDB(t)
requests := newAnthropicRequestRecorder()
anthropicURL := chattest.NewAnthropic(t, func(req *chattest.AnthropicRequest) chattest.AnthropicResponse {
requests.record(req)
if !req.Stream {
return chattest.AnthropicNonStreamingResponse("title")
}
return chattest.AnthropicStreamingResponse(chattest.AnthropicTextChunks("done")...)
})
user, org, model := seedAnthropicChatDependencies(t, db, anthropicURL)
if callConfig != nil {
model = updateChatModelCallConfig(t, db, model, *callConfig)
}
server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) {
cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, anthropicURL, chattest.WithPreservePath()))
})
chat := createChatThroughServer(ctx, t, db, server, org.ID, user.ID, model.ID, "hello")
waitForChatStatus(ctx, t, db, chat.ID, database.ChatStatusWaiting)
generationRequests := filterAnthropicStreamingRequests(requests.all())
require.NotEmpty(t, generationRequests)
return generationRequests
}
t.Run("enabled sends beta header", func(t *testing.T) {
t.Parallel()
generationRequests := runChat(t, &codersdk.ChatModelCallConfig{
ProviderOptions: &codersdk.ChatModelProviderOptions{
Anthropic: &codersdk.ChatModelAnthropicProviderOptions{
Context1MEnabled: ptr.Ref(true),
},
},
})
for _, req := range generationRequests {
require.Equal(t, chatprovider.AnthropicBetaContext1M, req.Header.Get(chatprovider.HeaderAnthropicBeta))
}
})
t.Run("unset omits beta header", func(t *testing.T) {
t.Parallel()
generationRequests := runChat(t, nil)
for _, req := range generationRequests {
require.Empty(t, req.Header.Get(chatprovider.HeaderAnthropicBeta))
}
})
}
func TestActiveServer_AnthropicProviderToolPreRequestGuard(t *testing.T) {
t.Parallel()
@@ -874,6 +874,30 @@ func CoderHeaders(chat database.Chat) map[string]string {
return h
}
// AnthropicBetaContext1M is the beta token for Anthropic's 1M context window.
const AnthropicBetaContext1M = "context-1m-2025-08-07"
// HeaderAnthropicBeta names Anthropic's beta feature header.
const HeaderAnthropicBeta = "Anthropic-Beta"
// BetaHeadersFromCallConfig returns beta feature headers for Anthropic and
// Bedrock calls.
func BetaHeadersFromCallConfig(providerName string, config *codersdk.ChatModelCallConfig) map[string]string {
if config == nil || config.ProviderOptions == nil || config.ProviderOptions.Anthropic == nil {
return nil
}
enabled := config.ProviderOptions.Anthropic.Context1MEnabled
if enabled == nil || !*enabled {
return nil
}
switch NormalizeProvider(providerName) {
case fantasyanthropic.Name, fantasybedrock.Name:
return map[string]string{HeaderAnthropicBeta: AnthropicBetaContext1M}
default:
return nil
}
}
// ModelFromConfig resolves a provider/model pair and constructs a fantasy
// language model client using the provided provider credentials. The
// userAgent is sent as the User-Agent header on every outgoing LLM
@@ -1,6 +1,7 @@
package chatprovider_test
import (
"context"
"encoding/base64"
"encoding/json"
"io"
@@ -1375,6 +1376,140 @@ func TestModelFromConfig_ExtraHeaders(t *testing.T) {
})
}
func TestBetaHeadersFromCallConfig(t *testing.T) {
t.Parallel()
configWith1M := func(enabled *bool) *codersdk.ChatModelCallConfig {
return &codersdk.ChatModelCallConfig{
ProviderOptions: &codersdk.ChatModelProviderOptions{
Anthropic: &codersdk.ChatModelAnthropicProviderOptions{
Context1MEnabled: enabled,
},
},
}
}
beta := map[string]string{
chatprovider.HeaderAnthropicBeta: chatprovider.AnthropicBetaContext1M,
}
tests := []struct {
name string
provider string
config *codersdk.ChatModelCallConfig
want map[string]string
}{
{name: "NilConfig", provider: fantasyanthropic.Name, config: nil, want: nil},
{name: "NilProviderOptions", provider: fantasyanthropic.Name, config: &codersdk.ChatModelCallConfig{}, want: nil},
{name: "NilAnthropicOptions", provider: fantasyanthropic.Name, config: &codersdk.ChatModelCallConfig{ProviderOptions: &codersdk.ChatModelProviderOptions{}}, want: nil},
{name: "Unset", provider: fantasyanthropic.Name, config: configWith1M(nil), want: nil},
{name: "Disabled", provider: fantasyanthropic.Name, config: configWith1M(ptr.Ref(false)), want: nil},
{name: "EnabledAnthropic", provider: fantasyanthropic.Name, config: configWith1M(ptr.Ref(true)), want: beta},
{name: "EnabledBedrock", provider: fantasybedrock.Name, config: configWith1M(ptr.Ref(true)), want: beta},
{name: "EnabledOpenAI", provider: fantasyopenai.Name, config: configWith1M(ptr.Ref(true)), want: nil},
{name: "EnabledUnknownProvider", provider: "does-not-exist", config: configWith1M(ptr.Ref(true)), want: nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tt.want, chatprovider.BetaHeadersFromCallConfig(tt.provider, tt.config))
})
}
}
func generateHello(ctx context.Context, model fantasy.LanguageModel) error {
_, err := model.Generate(ctx, fantasy.Call{
Prompt: []fantasy.Message{
{
Role: fantasy.MessageRoleUser,
Content: []fantasy.MessagePart{fantasy.TextPart{Text: "hello"}},
},
},
})
return err
}
func TestModelFromConfig_AnthropicBetaExtraHeader(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
called := make(chan struct{})
serverURL := chattest.NewAnthropic(t, func(req *chattest.AnthropicRequest) chattest.AnthropicResponse {
assert.Equal(t, chatprovider.AnthropicBetaContext1M, req.Header.Get(chatprovider.HeaderAnthropicBeta))
close(called)
return chattest.AnthropicNonStreamingResponse("hello")
})
keys := chatprovider.ProviderAPIKeys{
ByProvider: map[string]string{fantasyanthropic.Name: "test-key"},
BaseURLByProvider: map[string]string{fantasyanthropic.Name: serverURL},
}
betaHeaders := map[string]string{
chatprovider.HeaderAnthropicBeta: chatprovider.AnthropicBetaContext1M,
}
model, err := chatprovider.ModelFromConfig(fantasyanthropic.Name, "claude-sonnet-4-20250514", keys, chatprovider.UserAgent(), betaHeaders, nil)
require.NoError(t, err)
require.NoError(t, generateHello(ctx, model))
_ = testutil.TryReceive(ctx, t, called)
}
// The Anthropic SDK's Bedrock middleware moves Anthropic-Beta into the
// request body before SigV4 signing.
func TestModelFromConfig_BedrockBetaExtraHeader(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitShort)
t.Setenv("AWS_ACCESS_KEY_ID", "test-access-key")
t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret-key")
t.Setenv("AWS_SESSION_TOKEN", "test-session-token")
type requestCapture struct {
AnthropicBeta string
Authorization string
Body string
ReadError error
}
requests := make(chan requestCapture, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
requests <- requestCapture{
AnthropicBeta: r.Header.Get(chatprovider.HeaderAnthropicBeta),
Authorization: r.Header.Get("Authorization"),
Body: string(body),
ReadError: err,
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(bedrockNonStreamingResponse())
}))
defer server.Close()
model, err := chatprovider.ModelFromConfig(
fantasybedrock.Name,
"anthropic.claude-opus-4-6-v1",
chatprovider.ProviderAPIKeys{
ByProvider: map[string]string{fantasybedrock.Name: ""},
BaseURLByProvider: map[string]string{fantasybedrock.Name: server.URL},
RegionByProvider: map[string]string{fantasybedrock.Name: "us-east-2"},
},
chatprovider.UserAgent(),
map[string]string{
chatprovider.HeaderAnthropicBeta: chatprovider.AnthropicBetaContext1M,
},
nil,
)
require.NoError(t, err)
require.NoError(t, generateHello(ctx, model))
got := testutil.TryReceive(ctx, t, requests)
require.NoError(t, got.ReadError)
require.Empty(t, got.AnthropicBeta)
require.Contains(t, got.Authorization, "AWS4-HMAC-SHA256")
require.Contains(t, got.Body, `"anthropic_beta":["context-1m-2025-08-07"]`)
}
// TestModelFromConfig_AnthropicPDFFilePartReachesProvider pins the end-to-end
// path that lets a user-uploaded PDF actually reach Claude/Bedrock: a
// fantasy.FilePart with MediaType "application/pdf" must be serialized as an
+4
View File
@@ -2,6 +2,7 @@ package chatd
import (
"context"
"encoding/json"
"net/http"
"charm.land/fantasy"
@@ -18,6 +19,9 @@ type modelClientRequest struct {
ModelName string
UserAgent string
ExtraHeaders map[string]string
// ConfigOptions holds the model config row's Options JSONB; empty for
// paths without a config row.
ConfigOptions json.RawMessage
}
type modelBuildOptions struct {
+34 -1
View File
@@ -3,6 +3,7 @@ package chatd
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"strings"
@@ -166,16 +167,48 @@ func (p *Server) newModel(
}
config := fantasyConfigForAIBridge(route.Provider.Type)
extraHeaders, err := mergeConfigBetaHeaders(req.ExtraHeaders, config.ProviderHint, req.ConfigOptions)
if err != nil {
return nil, err
}
return newLanguageModel(
config.ProviderHint,
req.ModelName,
config.Keys,
req.UserAgent,
req.ExtraHeaders,
extraHeaders,
&http.Client{Transport: baseRT},
)
}
// mergeConfigBetaHeaders never mutates extraHeaders; existing entries win
// over config-derived ones.
func mergeConfigBetaHeaders(
extraHeaders map[string]string,
providerHint string,
configOptions json.RawMessage,
) (map[string]string, error) {
if len(configOptions) == 0 {
return extraHeaders, nil
}
var callConfig codersdk.ChatModelCallConfig
if err := json.Unmarshal(configOptions, &callConfig); err != nil {
return nil, xerrors.Errorf("parse model config options: %w", err)
}
betaHeaders := chatprovider.BetaHeadersFromCallConfig(providerHint, &callConfig)
if len(betaHeaders) == 0 {
return extraHeaders, nil
}
merged := make(map[string]string, len(extraHeaders)+len(betaHeaders))
for name, value := range betaHeaders {
merged[name] = value
}
for name, value := range extraHeaders {
merged[name] = value
}
return merged, nil
}
type aibridgeFantasyConfig struct {
ProviderHint string
Keys chatprovider.ProviderAPIKeys
+5 -4
View File
@@ -95,10 +95,11 @@ func (p *Server) resolveTitleGenerationModelOverride(
return database.ChatModelConfig{}, nil, aiGatewayModelRoute{}, true, err
}
model, err := p.newModel(ctx, modelClientRequest{
Chat: chat,
ModelName: modelConfig.Model,
UserAgent: chatprovider.UserAgent(),
ExtraHeaders: chatprovider.CoderHeaders(chat),
Chat: chat,
ModelName: modelConfig.Model,
UserAgent: chatprovider.UserAgent(),
ExtraHeaders: chatprovider.CoderHeaders(chat),
ConfigOptions: modelConfig.Options,
}, route, modelOpts)
if err != nil {
return database.ChatModelConfig{}, nil, aiGatewayModelRoute{}, true, xerrors.Errorf(
+1
View File
@@ -1354,6 +1354,7 @@ type ChatModelAnthropicProviderOptions struct {
WebSearchEnabled *bool `json:"web_search_enabled,omitempty" description:"Enable Anthropic web search tool for grounding responses with real-time information"`
AllowedDomains []string `json:"allowed_domains,omitempty" label:"Web Search: Allowed Domains" description:"Restrict web search to these domains (cannot be used with blocked_domains)" visible_when:"web_search_enabled" conflicts_with:"blocked_domains"`
BlockedDomains []string `json:"blocked_domains,omitempty" label:"Web Search: Blocked Domains" description:"Block web search on these domains (cannot be used with allowed_domains)" visible_when:"web_search_enabled" conflicts_with:"allowed_domains"`
Context1MEnabled *bool `json:"context_1m_enabled,omitempty" label:"1M Context Window" description:"Send the anthropic-beta context-1m-2025-08-07 header to unlock the 1M token context window on supported Claude models. Pair with a matching Context Limit. Long-context pricing and higher latency may apply above 200K tokens."`
}
// ChatModelGoogleThinkingConfig configures Google thinking behavior.
+5 -4
View File
@@ -188,10 +188,11 @@ fields appear dynamically in the admin UI when you select a provider.
#### Anthropic
| Option | Description |
|------------------------|------------------------------------------------------------------|
| Thinking Budget Tokens | Maximum tokens allocated for extended thinking. |
| Effort | Thinking effort level (`low`, `medium`, `high`, `xhigh`, `max`). |
| Option | Description |
|------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Thinking Budget Tokens | Maximum tokens allocated for extended thinking. |
| Effort | Thinking effort level (`low`, `medium`, `high`, `xhigh`, `max`). |
| 1M Context Window | Sends the `anthropic-beta: context-1m-2025-08-07` header to unlock the 1M token context window on supported Claude models. Pair it with a raised Context Limit, which still controls compaction. Long-context pricing may apply. |
#### OpenAI
@@ -169,6 +169,15 @@
"input_type": "json",
"visible_when": "web_search_enabled",
"conflicts_with": ["allowed_domains"]
},
{
"json_name": "context_1m_enabled",
"go_name": "Context1MEnabled",
"type": "boolean",
"description": "Send the anthropic-beta context-1m-2025-08-07 header to unlock the 1M token context window on supported Claude models. Pair with a matching Context Limit. Long-context pricing and higher latency may apply above 200K tokens.",
"label": "1M Context Window",
"required": false,
"input_type": "select"
}
]
},
+1
View File
@@ -2526,6 +2526,7 @@ export interface ChatModelAnthropicProviderOptions {
readonly web_search_enabled?: boolean;
readonly allowed_domains?: readonly string[];
readonly blocked_domains?: readonly string[];
readonly context_1m_enabled?: boolean;
}
// From codersdk/chats.go
@@ -324,6 +324,22 @@ describe("extractModelConfigFormState", () => {
expect(anthropic.disableParallelToolUse).toBe("false");
});
it("extracts Anthropic 1M context window option", () => {
const model: TypesGen.ChatModelConfig = {
...baseChatModelConfig,
model_config: {
provider_options: {
anthropic: {
context_1m_enabled: true,
},
},
},
};
const result = extractModelConfigFormState(model);
const anthropic = result.anthropic as Record<string, unknown>;
expect(anthropic.context1mEnabled).toBe("true");
});
it("extracts Google provider options with safety settings", () => {
const safetySettings = [{ category: "harm", threshold: "block" }];
const model: TypesGen.ChatModelConfig = {
@@ -835,6 +851,17 @@ describe("buildModelConfigFromForm", () => {
});
});
it("builds Anthropic options with 1M context window enabled", () => {
const result = buildModelConfigFromForm(
"anthropic",
formWith({ anthropic: { context1mEnabled: "true" } }),
);
expect(result.fieldErrors).toEqual({});
expect(result.modelConfig?.provider_options?.anthropic).toEqual({
context_1m_enabled: true,
});
});
it("builds Anthropic options with thinking budget", () => {
const result = buildModelConfigFromForm(
"anthropic",