mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat(coderd/x/chatd): add openaicompat provider options
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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")},
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
Generated
+11
@@ -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
|
||||
|
||||
+9
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user