feat(coderd/x/chatd): add openaicompat provider options

This commit is contained in:
Michael Suchacz
2026-05-16 17:29:01 +00:00
parent aa9ef66d81
commit 6c787fe3bb
13 changed files with 224 additions and 10 deletions
+1
View File
@@ -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),
+1 -1
View File
@@ -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")},
}
+18 -2
View File
@@ -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,
}
}
@@ -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)
})
}
+12 -2
View File
@@ -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
+5 -3
View File
@@ -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.
+2 -2
View File
@@ -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=
@@ -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
}
]
},
+11
View File
@@ -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<string, unknown>;
}
// From codersdk/chats.go
@@ -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();
},
};
@@ -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<string, unknown>;
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",
@@ -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");
});
});
+1
View File
@@ -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";