Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions coderd/exp_chats.go
Original file line number Diff line number Diff line change
Expand Up @@ -8179,10 +8179,15 @@ func isZeroChatModelCallConfig(config *codersdk.ChatModelCallConfig) bool {
config.PresencePenalty == nil &&
config.FrequencyPenalty == nil &&
config.ReasoningEffort == nil &&
isZeroChatModelOpenAIConfig(config.OpenAIConfig) &&
isZeroModelCostConfig(config.Cost) &&
isZeroChatModelProviderOptions(config.ProviderOptions)
}

func isZeroChatModelOpenAIConfig(config *codersdk.ChatModelOpenAIConfig) bool {
return config == nil || config.UseResponsesAPI == nil
}

func isZeroModelCostConfig(cost *codersdk.ModelCostConfig) bool {
if cost == nil {
return true
Expand Down
47 changes: 47 additions & 0 deletions coderd/exp_chats_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"testing"

"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"golang.org/x/xerrors"
Expand All @@ -19,6 +21,7 @@ import (
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbmock"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
Expand Down Expand Up @@ -371,3 +374,47 @@ func TestRewriteChatStartWorkspaceManualUpdateResponse(t *testing.T) {
})
}
}

// Every ChatModelCallConfig field must classify a config as non-zero when set,
// or unmarshalChatModelCallConfig hides it from API responses while the stored
// value stays active. Fails when a new field is added without a sample here.
func TestIsZeroChatModelCallConfigCoversEveryField(t *testing.T) {
t.Parallel()

costSample := decimal.NewFromInt(3)
sampled := codersdk.ChatModelCallConfig{
MaxOutputTokens: ptr.Ref(int64(4096)),
Temperature: ptr.Ref(0.7),
TopP: ptr.Ref(0.9),
TopK: ptr.Ref(int64(40)),
PresencePenalty: ptr.Ref(0.1),
FrequencyPenalty: ptr.Ref(0.2),
Cost: &codersdk.ModelCostConfig{
InputPricePerMillionTokens: &costSample,
},
ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{
Default: ptr.Ref("medium"),
},
OpenAIConfig: &codersdk.ChatModelOpenAIConfig{
UseResponsesAPI: ptr.Ref(true),
},
ProviderOptions: &codersdk.ChatModelProviderOptions{
OpenAI: &codersdk.ChatModelOpenAIProviderOptions{},
},
}

require.True(t, isZeroChatModelCallConfig(nil))
require.True(t, isZeroChatModelCallConfig(&codersdk.ChatModelCallConfig{}))

sampledValue := reflect.ValueOf(sampled)
for i := 0; i < sampledValue.NumField(); i++ {
field := sampledValue.Type().Field(i)
require.Falsef(t, sampledValue.Field(i).IsZero(),
"field %s needs a non-zero sample value", field.Name)

config := &codersdk.ChatModelCallConfig{}
reflect.ValueOf(config).Elem().Field(i).Set(sampledValue.Field(i))
require.Falsef(t, isZeroChatModelCallConfig(config),
"isZeroChatModelCallConfig ignores field %s", field.Name)
}
}
45 changes: 45 additions & 0 deletions coderd/x/chatd/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -854,6 +854,51 @@ Subagent spawning is a second source of both values. `spawn_agent` accepts optio

During generation preparation, the effective effort is resolved as the chat's `last_reasoning_effort` if set, else the config's `default`; clamped to the config's `max` on the global scale `none < minimal < low < medium < high < xhigh < max`; and passed through to the provider. The provider verifies whether the configured value is valid for that model at runtime. If the model config has no `reasoning_effort`, any user-selected value is ignored. The resolved value is injected into the provider-native options with `chatprovider.ApplyReasoningEffort` after provider option conversion. For Anthropic, the fantasy provider converts effort into enabled budget thinking on models older than Claude 4.6, which reject adaptive thinking.

##### OpenAI transport selection

OpenAI models speak either the Responses API or Chat Completions. The provider SDK picks per model from a static known-model list, so a newly released model absent from that list falls back to Chat Completions. Model configs may override the choice with `openai_config.use_responses_api` inside `chat_model_configs.options`: unset keeps the known-model list, true forces Responses, false forces Chat Completions. It sits in `openai_config` rather than `provider_options.openai` because it is applied once when the client is built, while `provider_options` holds per-request parameters.

The transport is decided in more than one place, and those decisions must agree with the client that was built. `chatopenai.UsesResponsesAPI` is the single predicate, and every path that builds an OpenAI client must pass the same override to both the client and the code that prepares its requests:

- Client construction (`ModelFromConfig`) installs the override as the SDK's per-model transport hook. The hook only selects among transports the client enables, so it cannot turn on Responses for a provider whose client was not built to allow it.
- Provider option conversion (`UsesResponsesOptions`) chooses between the Responses and Chat Completions option structs. The SDK type-asserts the concrete struct, so a mismatch silently discards every OpenAI provider option rather than failing.
- File part conversion (`AcceptsFilePartMediaType`) gates attachments, because the Responses API natively accepts only images and PDFs. A mismatch here silently drops text attachments.

Paths that build their own clients must thread the override too, including the compaction override, quick generation (used by turn status labels and debug models), and the advisor runtime.

Azure is deliberately exempt: its provider always enables the Responses API for known models and exposes no equivalent per-model hook, so `UsesResponsesAPI` keeps following the known-model list for Azure. Ignoring the override there is what keeps the decisions above in agreement with the Azure client. The exemption is narrower than it appears, because chatd never builds an azure-typed provider as a fantasy azure client: `fantasyConfigForAIBridge` folds every provider type other than anthropic, bedrock, and openai into openai-compat, which always speaks Chat Completions.

Both transports read the same `provider_options.openai` config, but not every field applies to both wire formats. The table below records, per field, which transport honors it; `TestProviderOptionsTransportParity` fails when a field is honored on one transport and silently ignored on the other without being recorded there as intentional.

| `provider_options.openai` field | Responses | Chat Completions |
| --- | --- | --- |
| `include` | yes | no |
| `instructions` | yes | no |
| `logit_bias` | no | yes |
| `log_probs` | yes | yes |
| `top_log_probs` | yes | yes |
| `max_tool_calls` | yes | no |
| `parallel_tool_calls` | yes | yes |
| `user` | yes | yes |
| `reasoning_summary` | yes | no |
| `max_completion_tokens` | no | yes |
| `text_verbosity` | yes | yes |
| `prediction` | no | yes |
| `store` | yes | yes |
| `metadata` | yes | yes |
| `prompt_cache_key` | yes | yes |
| `safety_identifier` | yes | yes |
| `service_tier` | yes | yes |
| `structured_outputs` | no | yes |
| `strict_json_schema` | yes | no |
| `web_search_enabled` | no | no |
| `search_context_size` | no | no |
| `allowed_domains` | no | no |

Three asymmetries are deliberate near-equivalents rather than gaps. `max_completion_tokens` is the Chat Completions cap; on Responses the transport-neutral `max_output_tokens` config bounds output instead. `structured_outputs` and `strict_json_schema` are the per-API strictness switches, each honored only by its own API. On Responses, `top_log_probs` wins over `log_probs` because that API takes a single logprobs value. The trailing web search fields configure tool wiring rather than per-request provider options, so neither transport reads them during option conversion.

The model editor scopes the field to openai-typed providers with a `providers` struct tag, which the option schema generator emits as `visible_for_providers`. Gating on the raw provider type rather than the alias table keeps the control out of editors for provider types that cannot honor it.

#### Compaction model selection

Compaction is an auxiliary LLM call: when the conversation approaches the context limit, the generation goroutine asks a model to summarize the history, commits the summary as a compressed boundary, and continues the turn on the chat model.
Expand Down
5 changes: 5 additions & 0 deletions coderd/x/chatd/chatd.go
Original file line number Diff line number Diff line change
Expand Up @@ -428,14 +428,17 @@ func (p *Server) newAdvisorRuntime(
nil,
advisorCallConfig.ReasoningEffort,
)
advisorResponsesOverride := chatprovider.OpenAIResponsesAPIOverride(advisorCallConfig.OpenAIConfig)
providerOptions := chatprovider.ProviderOptionsFromChatModelConfig(
advisorModel,
advisorCallConfig.ProviderOptions,
advisorResponsesOverride,
)
providerOptions = chatprovider.ApplyReasoningEffort(
advisorModel,
providerOptions,
advisorReasoningEffort,
advisorResponsesOverride,
)

rt, err := chatadvisor.NewRuntime(chatadvisor.RuntimeConfig{
Expand Down Expand Up @@ -3501,6 +3504,7 @@ type runChatResult struct {
FallbackRoute aiGatewayModelRoute
FallbackModel string
ModelBuildOptions modelBuildOptions
StatusLabelOptions json.RawMessage
TriggerMessageID int64
HistoryTipMessageID int64
}
Expand Down Expand Up @@ -4690,6 +4694,7 @@ func (p *Server) generateFinalTurnStatusLabel(
runResult.StatusLabelModel,
runResult.FallbackRoute,
runResult.ModelBuildOptions,
runResult.StatusLabelOptions,
logger,
p.existingDebugService(),
runResult.TriggerMessageID,
Expand Down
56 changes: 35 additions & 21 deletions coderd/x/chatd/chatopenai/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ import (
func ProviderOptionsFromChatConfig(
model fantasy.LanguageModel,
options *codersdk.ChatModelOpenAIProviderOptions,
openAIResponsesOverride *bool,
) fantasy.ProviderOptionsData {
if UsesResponsesOptions(model) {
if UsesResponsesOptions(model, openAIResponsesOverride) {
include := EnsureResponseIncludes(IncludeFromChat(options.Include))
providerOptions := &fantasyopenai.ResponsesProviderOptions{
Include: include,
Expand Down Expand Up @@ -116,40 +117,53 @@ func EnsureResponseIncludes(
return append(values, required)
}

// UsesResponsesAPI reports whether a model uses the OpenAI Responses API.
// Callers must pass the same override the client was built with.
func UsesResponsesAPI(provider, modelID string, override *bool) bool {
switch provider {
case fantasyopenai.Name:
if override != nil {
return *override
}
return fantasyopenai.IsResponsesModel(modelID)
case fantasyazure.Name:
return fantasyopenai.IsResponsesModel(modelID)
default:
return false
}
}
Comment on lines +122 to +134

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Forgive me if there is a justification, but why have we created a new function UsesResponsesAPI despite it not appearing to add any value over UsesResponsesOptions?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They take different inputs at different stages: UsesResponsesAPI works from (provider, modelID, override) strings and is called during client construction in ModelFromConfig, before any fantasy.LanguageModel exists. UsesResponsesOptions is the wrapper for request preparation, where callers hold a model. Both are transitional: #27704 in this stack deletes the pair entirely in favor of a transport resolved once at construction and carried on the model.

Mux replied on Mike's behalf.


// UsesResponsesOptions reports whether the model should use OpenAI Responses
// API provider options.
func UsesResponsesOptions(model fantasy.LanguageModel) bool {
func UsesResponsesOptions(model fantasy.LanguageModel, override *bool) bool {
if model == nil {
return false
}
switch model.Provider() {
case fantasyopenai.Name, fantasyazure.Name:
return fantasyopenai.IsResponsesModel(model.Model())
default:
return false
}
return UsesResponsesAPI(model.Provider(), model.Model(), override)
}

// ServiceTierFromChat normalizes chat-config service tier values for OpenAI
// Responses API and returns the canonical provider service tier value.
// ServiceTierFromChat normalizes chat-config service tier values for the
// OpenAI Responses API. It maps every tier the codersdk enum advertises, not
// only the ones fantasy declares constants for, because fantasy forwards the
// value to the API unchanged.
func ServiceTierFromChat(value *string) *fantasyopenai.ServiceTier {
normalized := chatutil.NormalizedStringPointer(value)
if normalized == nil {
return nil
}
switch strings.ToLower(*normalized) {
case string(fantasyopenai.ServiceTierAuto):
serviceTier := fantasyopenai.ServiceTierAuto
return &serviceTier
case string(fantasyopenai.ServiceTierFlex):
serviceTier := fantasyopenai.ServiceTierFlex
return &serviceTier
case string(fantasyopenai.ServiceTierPriority):
serviceTier := fantasyopenai.ServiceTierPriority
return &serviceTier
default:
tier := chatutil.NormalizedEnumValue(
strings.ToLower(*normalized),
string(fantasyopenai.ServiceTierAuto),
"default",
string(fantasyopenai.ServiceTierFlex),
"scale",
string(fantasyopenai.ServiceTierPriority),
)
if tier == nil {
return nil
}
serviceTier := fantasyopenai.ServiceTier(*tier)
return &serviceTier
}

// ResponsesLogProbsFromChatConfig maps chat-config log probability options to the
Expand Down
43 changes: 38 additions & 5 deletions coderd/x/chatd/chatopenai/options_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ func TestProviderOptionsFromChatConfigLegacy(t *testing.T) {
got := chatopenai.ProviderOptionsFromChatConfig(
fakeLanguageModel{provider: fantasyopenai.Name, model: "gpt-3.5-turbo-instruct"},
options,
nil,
)

providerOptions, ok := got.(*fantasyopenai.ProviderOptions)
Expand Down Expand Up @@ -98,6 +99,7 @@ func TestProviderOptionsFromChatConfigResponses(t *testing.T) {
got := chatopenai.ProviderOptionsFromChatConfig(
fakeLanguageModel{provider: fantasyopenai.Name, model: "gpt-4.1"},
options,
nil,
)

providerOptions, ok := got.(*fantasyopenai.ResponsesProviderOptions)
Expand Down Expand Up @@ -243,10 +245,14 @@ func TestEnsureResponseIncludes(t *testing.T) {
func TestUsesResponsesOptions(t *testing.T) {
t.Parallel()

forceResponses := true
forceCompletions := false

tests := []struct {
name string
model fantasy.LanguageModel
want bool
name string
model fantasy.LanguageModel
override *bool
want bool
}{
{name: "Nil"},
{
Expand All @@ -267,13 +273,39 @@ func TestUsesResponsesOptions(t *testing.T) {
name: "NonOpenAIProvider",
model: fakeLanguageModel{provider: "other", model: "gpt-4.1"},
},
{
name: "NilModelIgnoresOverride",
override: &forceResponses,
},
{
name: "OverrideForcesResponsesForUnknownModel",
model: fakeLanguageModel{provider: fantasyopenai.Name, model: "gpt-9-brand-new"},
override: &forceResponses,
want: true,
},
{
name: "OverrideForcesCompletionsForResponsesModel",
model: fakeLanguageModel{provider: fantasyopenai.Name, model: "gpt-4.1"},
override: &forceCompletions,
},
{
name: "AzureIgnoresOverride",
model: fakeLanguageModel{provider: fantasyazure.Name, model: "gpt-4.1"},
override: &forceCompletions,
want: true,
},
{
name: "NonOpenAIProviderIgnoresOverride",
model: fakeLanguageModel{provider: "other", model: "gpt-4.1"},
override: &forceResponses,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

got := chatopenai.UsesResponsesOptions(tt.model)
got := chatopenai.UsesResponsesOptions(tt.model, tt.override)
require.Equal(t, tt.want, got)
})
}
Expand All @@ -292,7 +324,8 @@ func TestServiceTierFromChat(t *testing.T) {
{name: "Auto", value: ptr(" auto "), want: ptr(fantasyopenai.ServiceTierAuto)},
{name: "FlexCase", value: ptr(" FLEX "), want: ptr(fantasyopenai.ServiceTierFlex)},
{name: "Priority", value: ptr("priority"), want: ptr(fantasyopenai.ServiceTierPriority)},
{name: "DefaultUnsupported", value: ptr("default")},
{name: "Default", value: ptr("default"), want: ptr(fantasyopenai.ServiceTier("default"))},
{name: "ScaleCase", value: ptr(" Scale "), want: ptr(fantasyopenai.ServiceTier("scale"))},
{name: "Invalid", value: ptr("fast")},
}

Expand Down
Loading
Loading