diff --git a/coderd/x/chatd/chatopenai/options.go b/coderd/x/chatd/chatopenai/options.go index 91d87fe582..c81da0df48 100644 --- a/coderd/x/chatd/chatopenai/options.go +++ b/coderd/x/chatd/chatopenai/options.go @@ -147,6 +147,7 @@ func ReasoningEffortFromChat(value *string) *fantasyopenai.ReasoningEffort { effort := chatutil.NormalizedEnumValue( normalized, + string(fantasyopenai.ReasoningEffortNone), string(fantasyopenai.ReasoningEffortMinimal), string(fantasyopenai.ReasoningEffortLow), string(fantasyopenai.ReasoningEffortMedium), diff --git a/coderd/x/chatd/chatopenai/options_test.go b/coderd/x/chatd/chatopenai/options_test.go index 1320300b11..9870fbd304 100644 --- a/coderd/x/chatd/chatopenai/options_test.go +++ b/coderd/x/chatd/chatopenai/options_test.go @@ -296,7 +296,7 @@ func TestReasoningEffortFromChat(t *testing.T) { {name: "Medium", value: ptr("medium"), want: ptr(fantasyopenai.ReasoningEffortMedium)}, {name: "High", value: ptr("high"), want: ptr(fantasyopenai.ReasoningEffortHigh)}, {name: "XHigh", value: ptr("xhigh"), want: ptr(fantasyopenai.ReasoningEffortXHigh)}, - {name: "NoneUnsupported", value: ptr("none")}, + {name: "None", value: ptr("none"), want: ptr(fantasyopenai.ReasoningEffortNone)}, {name: "Invalid", value: ptr("max")}, } diff --git a/coderd/x/chatd/chatprovider/chatprovider.go b/coderd/x/chatd/chatprovider/chatprovider.go index fec0840b08..0938da2d51 100644 --- a/coderd/x/chatd/chatprovider/chatprovider.go +++ b/coderd/x/chatd/chatprovider/chatprovider.go @@ -943,9 +943,21 @@ func MergeMissingProviderOptions( if dstCompat.User == nil { dstCompat.User = defaultCompat.User } + if dstCompat.ParallelToolCalls == nil { + dstCompat.ParallelToolCalls = defaultCompat.ParallelToolCalls + } if dstCompat.ReasoningEffort == nil { dstCompat.ReasoningEffort = defaultCompat.ReasoningEffort } + if dstCompat.MaxCompletionTokens == nil { + dstCompat.MaxCompletionTokens = defaultCompat.MaxCompletionTokens + } + if dstCompat.PromptCacheKey == nil { + dstCompat.PromptCacheKey = defaultCompat.PromptCacheKey + } + if dstCompat.ExtraBody == nil { + dstCompat.ExtraBody = defaultCompat.ExtraBody + } case fantasyopenrouter.Name: if defaults.OpenRouter == nil { @@ -1426,8 +1438,12 @@ func openAICompatProviderOptionsFromChatConfig( options *codersdk.ChatModelOpenAICompatProviderOptions, ) *fantasyopenaicompat.ProviderOptions { return &fantasyopenaicompat.ProviderOptions{ - User: chatutil.NormalizedStringPointer(options.User), - ReasoningEffort: chatopenai.ReasoningEffortFromChat(options.ReasoningEffort), + User: chatutil.NormalizedStringPointer(options.User), + ParallelToolCalls: options.ParallelToolCalls, + ReasoningEffort: chatopenai.ReasoningEffortFromChat(options.ReasoningEffort), + MaxCompletionTokens: options.MaxCompletionTokens, + PromptCacheKey: options.PromptCacheKey, + ExtraBody: options.ExtraBody, } } diff --git a/coderd/x/chatd/chatprovider/chatprovider_test.go b/coderd/x/chatd/chatprovider/chatprovider_test.go index 0c2cebfbad..93e39bffc9 100644 --- a/coderd/x/chatd/chatprovider/chatprovider_test.go +++ b/coderd/x/chatd/chatprovider/chatprovider_test.go @@ -1510,3 +1510,98 @@ func TestResolveModelWithProviderHint(t *testing.T) { }) } } + +func TestMergeMissingProviderOptions_OpenAICompat(t *testing.T) { + t.Parallel() + + options := &codersdk.ChatModelProviderOptions{ + OpenAICompat: &codersdk.ChatModelOpenAICompatProviderOptions{ + ReasoningEffort: ptr.Ref("low"), + }, + } + defaults := &codersdk.ChatModelProviderOptions{ + OpenAICompat: &codersdk.ChatModelOpenAICompatProviderOptions{ + User: ptr.Ref("default-user"), + ParallelToolCalls: ptr.Ref(true), + ReasoningEffort: ptr.Ref("high"), + MaxCompletionTokens: ptr.Ref[int64](4096), + PromptCacheKey: ptr.Ref("default-cache-key"), + ExtraBody: map[string]any{ + "default_field": "default-value", + }, + }, + } + + chatprovider.MergeMissingProviderOptions(&options, defaults) + + require.NotNil(t, options) + require.NotNil(t, options.OpenAICompat) + require.Equal(t, "default-user", *options.OpenAICompat.User) + require.True(t, *options.OpenAICompat.ParallelToolCalls) + require.Equal(t, "low", *options.OpenAICompat.ReasoningEffort) + require.EqualValues(t, 4096, *options.OpenAICompat.MaxCompletionTokens) + require.Equal(t, map[string]any{ + "default_field": "default-value", + }, options.OpenAICompat.ExtraBody) + require.Equal(t, "default-cache-key", *options.OpenAICompat.PromptCacheKey) +} + +func TestProviderOptionsFromChatModelConfig_OpenAICompat(t *testing.T) { + t.Parallel() + + t.Run("ForwardsOpenAICompatProviderOptions", func(t *testing.T) { + t.Parallel() + + providerOptions := chatprovider.ProviderOptionsFromChatModelConfig( + nil, + &codersdk.ChatModelProviderOptions{ + OpenAICompat: &codersdk.ChatModelOpenAICompatProviderOptions{ + User: ptr.Ref("compat-user"), + ParallelToolCalls: ptr.Ref(true), + ReasoningEffort: ptr.Ref("medium"), + MaxCompletionTokens: ptr.Ref[int64](2048), + PromptCacheKey: ptr.Ref("cache-key"), + ExtraBody: map[string]any{ + "custom_field": "custom-value", + }, + }, + }, + ) + + raw := providerOptions[fantasyopenaicompat.Name] + require.NotNil(t, raw) + options, ok := raw.(*fantasyopenaicompat.ProviderOptions) + require.True(t, ok) + require.Equal(t, "compat-user", *options.User) + require.True(t, *options.ParallelToolCalls) + require.NotNil(t, options.ReasoningEffort) + require.Equal(t, fantasyopenai.ReasoningEffortMedium, *options.ReasoningEffort) + require.EqualValues(t, 2048, *options.MaxCompletionTokens) + require.Equal(t, map[string]any{ + "custom_field": "custom-value", + }, options.ExtraBody) + require.Equal(t, "cache-key", *options.PromptCacheKey) + }) + + t.Run("LeavesUnsetOpenAICompatProviderOptionsNil", func(t *testing.T) { + t.Parallel() + + providerOptions := chatprovider.ProviderOptionsFromChatModelConfig( + nil, + &codersdk.ChatModelProviderOptions{ + OpenAICompat: &codersdk.ChatModelOpenAICompatProviderOptions{}, + }, + ) + + raw := providerOptions[fantasyopenaicompat.Name] + require.NotNil(t, raw) + options, ok := raw.(*fantasyopenaicompat.ProviderOptions) + require.True(t, ok) + require.Nil(t, options.User) + require.Nil(t, options.ParallelToolCalls) + require.Nil(t, options.ReasoningEffort) + require.Nil(t, options.MaxCompletionTokens) + require.Nil(t, options.ExtraBody) + require.Nil(t, options.PromptCacheKey) + }) +} diff --git a/codersdk/chats.go b/codersdk/chats.go index bcf235f590..68bdf37921 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -1257,9 +1257,19 @@ type ChatModelGoogleProviderOptions struct { } // ChatModelOpenAICompatProviderOptions configures OpenAI-compatible behavior. +// +// Every field on this struct must be a nilable type (pointer, slice, or map) +// so JSON omitempty can distinguish an unset field from an explicit zero +// value. Adding a bare bool, int, or string field would silently lose user +// intent because false, 0, and "" would be indistinguishable from unset at the +// SDK boundary and on the wire. type ChatModelOpenAICompatProviderOptions struct { - User *string `json:"user,omitempty" description:"Unique identifier for the end user for abuse monitoring" hidden:"true"` - ReasoningEffort *string `json:"reasoning_effort,omitempty" description:"Controls the level of reasoning effort" enum:"none,minimal,low,medium,high,xhigh"` + User *string `json:"user,omitempty" description:"Unique identifier for the end user for abuse monitoring" hidden:"true"` + ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty" description:"Whether the model may make multiple tool calls in parallel"` + ReasoningEffort *string `json:"reasoning_effort,omitempty" description:"Controls the level of reasoning effort" enum:"none,minimal,low,medium,high,xhigh"` + MaxCompletionTokens *int64 `json:"max_completion_tokens,omitempty" description:"Upper bound on tokens the model may generate"` + PromptCacheKey *string `json:"prompt_cache_key,omitempty" description:"Key for enabling cross-request prompt caching"` + ExtraBody map[string]any `json:"extra_body,omitempty" description:"Additional fields to include in the request body" hidden:"true"` } // ChatModelReasoningOptions configures reasoning behavior for model diff --git a/go.mod b/go.mod index d4ea36cb27..d1857e39c5 100644 --- a/go.mod +++ b/go.mod @@ -79,7 +79,7 @@ replace github.com/spf13/afero => github.com/aslilac/afero v0.0.0-20250403163713 // Forked from coder/fantasy (coder_2_33) which adds: // 1) Anthropic computer use + thinking effort // 2) Go 1.25 downgrade for Windows CI compat -// 3) ibetitsmike/fantasy#4 — skip ephemeral replay items when store=false +// 3) ibetitsmike/fantasy#4, skip ephemeral replay items when store=false // 4) (anthropic-sdk-go) dannykopping's appendCompact performance fixes // 5) (anthropic-sdk-go) DirectEncoder to eliminate nested MarshalJSON allocation chain // 6) Anthropic EffortXHigh constant for Claude Opus 4.7 @@ -90,8 +90,10 @@ replace github.com/spf13/afero => github.com/aslilac/afero v0.0.0-20250403163713 // streams close before their terminal events. // 9) coder/fantasy#35, preserve Anthropic replay fidelity for signed // reasoning and provider-executed web_search error results. -// See: https://github.com/coder/fantasy/commits/cfca5fd82c5dd -replace charm.land/fantasy => github.com/coder/fantasy v0.0.0-20260514123132-cfca5fd82c5d +// 10) coder/fantasy#34, typed OpenAI-compatible parallel_tool_calls, +// max_completion_tokens, prompt_cache_key, and extra_body options. +// See: https://github.com/coder/fantasy/commits/640407e67c72 +replace charm.land/fantasy => github.com/coder/fantasy v0.0.0-20260601144839-640407e67c72 // coder/coder uses a fork of charmbracelet's fork of the Anthropic Go SDK // with performance improvements and Bedrock header cleanup. diff --git a/go.sum b/go.sum index 5840cb7bf5..0b467abf50 100644 --- a/go.sum +++ b/go.sum @@ -324,8 +324,8 @@ github.com/coder/bubbletea v1.2.2-0.20241212190825-007a1cdb2c41 h1:SBN/DA63+ZHwu github.com/coder/bubbletea v1.2.2-0.20241212190825-007a1cdb2c41/go.mod h1:I9ULxr64UaOSUv7hcb3nX4kowodJCVS7vt7VVJk/kW4= github.com/coder/clistat v1.2.1 h1:P9/10njXMyj5cWzIU5wkRsSy5LVQH49+tcGMsAgWX0w= github.com/coder/clistat v1.2.1/go.mod h1:m7SC0uj88eEERgvF8Kn6+w6XF21BeSr+15f7GoLAw0A= -github.com/coder/fantasy v0.0.0-20260514123132-cfca5fd82c5d h1:CS3b2CZUDdHMwwtDoAtZF2/dzZd57yJtSJi3t86pmxE= -github.com/coder/fantasy v0.0.0-20260514123132-cfca5fd82c5d/go.mod h1:wZ0e3lEPqrM0XiIdAUQLvMKCLYhc3gi96MRX2wjbX44= +github.com/coder/fantasy v0.0.0-20260601144839-640407e67c72 h1:B/yZ3/j25b9kh0UMZywfPY2Ysxa62rFQTZzVBB6mCnI= +github.com/coder/fantasy v0.0.0-20260601144839-640407e67c72/go.mod h1:d3BHfn1TWaxcpH1ukH/Hs/u7DSpRyNoTbNXyU4Xig0M= github.com/coder/flog v1.1.0 h1:kbAes1ai8fIS5OeV+QAnKBQE22ty1jRF/mcAwHpLBa4= github.com/coder/flog v1.1.0/go.mod h1:UQlQvrkJBvnRGo69Le8E24Tcl5SJleAAR7gYEHzAmdQ= github.com/coder/go-httpstat v0.0.0-20230801153223-321c88088322 h1:m0lPZjlQ7vdVpRBPKfYIFlmgevoTkBxB10wv6l2gOaU= diff --git a/site/src/api/chatModelOptionsGenerated.json b/site/src/api/chatModelOptionsGenerated.json index 8af34d6d2c..439266b368 100644 --- a/site/src/api/chatModelOptionsGenerated.json +++ b/site/src/api/chatModelOptionsGenerated.json @@ -418,6 +418,14 @@ "input_type": "input", "hidden": true }, + { + "json_name": "parallel_tool_calls", + "go_name": "ParallelToolCalls", + "type": "boolean", + "description": "Whether the model may make multiple tool calls in parallel", + "required": false, + "input_type": "select" + }, { "json_name": "reasoning_effort", "go_name": "ReasoningEffort", @@ -426,6 +434,31 @@ "required": false, "enum": ["none", "minimal", "low", "medium", "high", "xhigh"], "input_type": "select" + }, + { + "json_name": "max_completion_tokens", + "go_name": "MaxCompletionTokens", + "type": "integer", + "description": "Upper bound on tokens the model may generate", + "required": false, + "input_type": "input" + }, + { + "json_name": "prompt_cache_key", + "go_name": "PromptCacheKey", + "type": "string", + "description": "Key for enabling cross-request prompt caching", + "required": false, + "input_type": "input" + }, + { + "json_name": "extra_body", + "go_name": "ExtraBody", + "type": "object", + "description": "Additional fields to include in the request body", + "required": false, + "input_type": "json", + "hidden": true } ] }, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index a42e1489ff..2559921e67 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2355,10 +2355,21 @@ export interface ChatModelGoogleThinkingConfig { // From codersdk/chats.go /** * ChatModelOpenAICompatProviderOptions configures OpenAI-compatible behavior. + * + * Every field on this struct must be a nilable type (pointer, slice, or map) + * so JSON omitempty can distinguish an unset field from an explicit zero + * value. Adding a bare bool, int, or string field would silently lose user + * intent because false, 0, and "" would be indistinguishable from unset at the + * SDK boundary and on the wire. */ export interface ChatModelOpenAICompatProviderOptions { readonly user?: string; + readonly parallel_tool_calls?: boolean; readonly reasoning_effort?: string; + readonly max_completion_tokens?: number; + readonly prompt_cache_key?: string; + // empty interface{} type, falling back to unknown + readonly extra_body?: Record; } // From codersdk/chats.go diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx index 09a5386369..efe4862a10 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx @@ -2030,6 +2030,15 @@ export const ModelFormOpenAICompat: Story = { await expect( await body.findByLabelText(/Reasoning Effort/i), ).toBeInTheDocument(); + await expect( + await body.findByLabelText(/Parallel Tool Calls/i), + ).toBeInTheDocument(); + await expect( + await body.findByLabelText(/Max Completion Tokens/i), + ).toBeInTheDocument(); + await expect( + await body.findByLabelText(/Prompt Cache Key/i), + ).toBeInTheDocument(); }, }; diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/modelConfigFormLogic.test.ts b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/modelConfigFormLogic.test.ts index aef8ae351b..290e8016ab 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/modelConfigFormLogic.test.ts +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/modelConfigFormLogic.test.ts @@ -357,6 +357,10 @@ describe("extractModelConfigFormState", () => { provider_options: { openaicompat: { reasoning_effort: "low", + parallel_tool_calls: true, + max_completion_tokens: 1024, + prompt_cache_key: "compat-cache-key", + extra_body: { custom_field: "custom-value" }, user: "compat-user", }, }, @@ -365,6 +369,12 @@ describe("extractModelConfigFormState", () => { const result = extractModelConfigFormState(model); const openaicompat = result.openaicompat as Record; expect(openaicompat.reasoningEffort).toBe("low"); + expect(openaicompat.parallelToolCalls).toBe("true"); + expect(openaicompat.maxCompletionTokens).toBe("1024"); + expect(openaicompat.promptCacheKey).toBe("compat-cache-key"); + expect(openaicompat.extraBody).toBe( + JSON.stringify({ custom_field: "custom-value" }, null, 2), + ); expect(openaicompat.user).toBe("compat-user"); }); @@ -902,6 +912,10 @@ describe("buildModelConfigFromForm", () => { formWith({ openaicompat: { reasoningEffort: "low", + parallelToolCalls: "true", + maxCompletionTokens: "2048", + promptCacheKey: "compat-cache-key", + extraBody: JSON.stringify({ custom_field: "custom-value" }), user: "compat-user", }, }), @@ -909,6 +923,10 @@ describe("buildModelConfigFromForm", () => { expect(result.fieldErrors).toEqual({}); expect(result.modelConfig?.provider_options?.openaicompat).toEqual({ reasoning_effort: "low", + parallel_tool_calls: true, + max_completion_tokens: 2048, + prompt_cache_key: "compat-cache-key", + extra_body: { custom_field: "custom-value" }, user: "compat-user", }); }); @@ -923,6 +941,23 @@ describe("buildModelConfigFromForm", () => { ); }); + it("omits blank optional openaicompat fields", () => { + const result = buildModelConfigFromForm( + "openaicompat", + formWith({ + temperature: "0.5", + openaicompat: { + parallelToolCalls: "", + maxCompletionTokens: " ", + promptCacheKey: " ", + extraBody: " ", + }, + }), + ); + expect(result.fieldErrors).toEqual({}); + expect(result.modelConfig?.provider_options).toBeUndefined(); + }); + it("does not set provider_options when all fields empty", () => { const result = buildModelConfigFromForm( "openaicompat", diff --git a/site/src/pages/AgentsPage/utils/modelOptions.test.ts b/site/src/pages/AgentsPage/utils/modelOptions.test.ts index 9a6ee41375..20c56f103e 100644 --- a/site/src/pages/AgentsPage/utils/modelOptions.test.ts +++ b/site/src/pages/AgentsPage/utils/modelOptions.test.ts @@ -225,6 +225,7 @@ describe("formatProviderLabel", () => { it("formats OpenAI compatible providers", () => { expect(formatProviderLabel("openai-compat")).toBe("OpenAI-compatible"); expect(formatProviderLabel("openai-compatible")).toBe("OpenAI-compatible"); + expect(formatProviderLabel("openaicompat")).toBe("OpenAI-compatible"); }); }); diff --git a/site/src/utils/aiProviders.ts b/site/src/utils/aiProviders.ts index e91b37cf66..20373751a8 100644 --- a/site/src/utils/aiProviders.ts +++ b/site/src/utils/aiProviders.ts @@ -14,6 +14,7 @@ export const formatProviderLabel = (provider: string): string => { case "openai-compat": case "openai-compatible": case "openai_compatible": + case "openaicompat": return "OpenAI-compatible"; case "openrouter": return "OpenRouter";