mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add per-model OpenAI Responses API toggle (#27683)
chatd hardcoded `WithUseResponsesAPI()`, so the provider SDK's static known-model list decided whether an OpenAI model spoke the Responses API or Chat Completions. A model absent from that list silently fell back to Chat Completions until the fantasy fork was patched. This exposes the SDK's `WithResponsesAPIFunc` hook as a per-model setting, `openai_config.use_responses_api`, stored in the existing `chat_model_configs.options` JSONB. Unset keeps the known-model list, `true` forces Responses, `false` forces Chat Completions. There is no migration. It sits in a new construction-time `openai_config` section rather than in `provider_options.openai` because it selects the API when the client is built, while `provider_options` holds per-request parameters. That placement is also load-bearing: a config setting only this field would otherwise materialize an OpenAI request-options struct and turn on provider-side response storage, since `Store` defaults to true there. Three places independently decided the transport and would silently disagree with the client actually built: | Site | Effect when it disagrees | | --- | --- | | `ModelFromConfig` | the transport being overridden | | `AcceptsFilePartMediaType` | text attachments dropped, since Responses natively accepts only images and PDFs | | `UsesResponsesOptions` | the SDK type-asserts the concrete options struct, so every OpenAI option is discarded | They share one predicate here, `chatopenai.UsesResponsesAPI`, with the override threaded to each. The rest of the stack removes that threading by resolving the transport once and carrying it. Compaction overrides and the quickgen debug model built clients without `ConfigOptions`, so they now pass it and pick up both this setting and the existing Anthropic beta headers. The toggle also makes transport-conditional option handling admin-switchable, so two hardening changes ride along. `ServiceTierFromChat` now maps every tier the codersdk enum advertises (`auto`, `default`, `flex`, `scale`, `priority`); it previously returned nil for `default` and `scale`, so flipping a model to Responses silently dropped a configured `service_tier` that the API accepts (fantasy forwards the value unchanged). And a new `TestProviderOptionsTransportParity` pins, per `provider_options.openai` field, which transport honors it, against a table in ARCHITECTURE.md, so a field honored on one transport and silently ignored on the other fails the test unless recorded as intentional. Review rounds also caught two lifecycle gaps around the new field. `isZeroChatModelCallConfig` now inspects `OpenAIConfig`, so a stored options blob whose only setting is this toggle survives into GET/list responses instead of reading as `model_config: null`; `TestIsZeroChatModelCallConfigCoversEveryField` sets each config field in isolation and fails if any field is invisible to the zero check. And the model editor's update path sends an explicit empty `model_config` when an edit clears the last field, since an omitted property preserves the stored options server-side; covered by the `EditClearingLastOptionSendsEmptyConfig` story. Azure keeps following the known-model list, because the Azure provider exposes no equivalent hook. The model editor renders Azure with the OpenAI option schema, so instead of shipping a visible but inert control, the option schema generator gains a `providers` struct tag that it emits as `visible_for_providers`. Gating uses the raw provider type rather than the alias table, so the control appears only for openai-typed providers. No hand-written frontend field: the editor renders it from the generated schema. Closes https://linear.app/codercom/issue/CODAGT-874/add-completionsresponses-api-toggle-in-model-editor > Mux prepared this PR on Mike's behalf.
This commit is contained in:
@@ -8201,10 +8201,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
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
)
|
||||
@@ -407,3 +410,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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{
|
||||
@@ -3501,6 +3504,7 @@ type runChatResult struct {
|
||||
FallbackRoute aiGatewayModelRoute
|
||||
FallbackModel string
|
||||
ModelBuildOptions modelBuildOptions
|
||||
StatusLabelOptions json.RawMessage
|
||||
TriggerMessageID int64
|
||||
HistoryTipMessageID int64
|
||||
}
|
||||
@@ -4690,6 +4694,7 @@ func (p *Server) generateFinalTurnStatusLabel(
|
||||
runResult.StatusLabelModel,
|
||||
runResult.FallbackRoute,
|
||||
runResult.ModelBuildOptions,
|
||||
runResult.StatusLabelOptions,
|
||||
logger,
|
||||
p.existingDebugService(),
|
||||
runResult.TriggerMessageID,
|
||||
|
||||
@@ -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,
|
||||
@@ -116,40 +117,53 @@ func EnsureResponseIncludes(
|
||||
return append(values, required)
|
||||
}
|
||||
|
||||
// UsesResponsesOptions reports whether the model should use OpenAI Responses
|
||||
// API provider options.
|
||||
func UsesResponsesOptions(model fantasy.LanguageModel) bool {
|
||||
if model == nil {
|
||||
return false
|
||||
}
|
||||
switch model.Provider() {
|
||||
case fantasyopenai.Name, fantasyazure.Name:
|
||||
return fantasyopenai.IsResponsesModel(model.Model())
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// ServiceTierFromChat normalizes chat-config service tier values for OpenAI
|
||||
// Responses API and returns the canonical provider service tier value.
|
||||
// UsesResponsesOptions reports whether the model should use OpenAI Responses
|
||||
// API provider options.
|
||||
func UsesResponsesOptions(model fantasy.LanguageModel, override *bool) bool {
|
||||
if model == nil {
|
||||
return false
|
||||
}
|
||||
return UsesResponsesAPI(model.Provider(), model.Model(), override)
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"},
|
||||
{
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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")},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package chatopenai_test
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
fantasyopenai "charm.land/fantasy/providers/openai"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatopenai"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
// Keep this map in sync with the OpenAI transport selection table in
|
||||
// ARCHITECTURE.md so intentional transport asymmetries remain explicit.
|
||||
var openAIOptionTransportSupport = map[string]struct {
|
||||
responses bool
|
||||
chatCompletions bool
|
||||
}{
|
||||
"include": {responses: true},
|
||||
"instructions": {responses: true},
|
||||
"logit_bias": {chatCompletions: true},
|
||||
"log_probs": {responses: true, chatCompletions: true},
|
||||
"top_log_probs": {responses: true, chatCompletions: true},
|
||||
"max_tool_calls": {responses: true},
|
||||
"parallel_tool_calls": {responses: true, chatCompletions: true},
|
||||
"user": {responses: true, chatCompletions: true},
|
||||
"reasoning_summary": {responses: true},
|
||||
"max_completion_tokens": {chatCompletions: true},
|
||||
"text_verbosity": {responses: true, chatCompletions: true},
|
||||
"prediction": {chatCompletions: true},
|
||||
"store": {responses: true, chatCompletions: true},
|
||||
"metadata": {responses: true, chatCompletions: true},
|
||||
"prompt_cache_key": {responses: true, chatCompletions: true},
|
||||
"safety_identifier": {responses: true, chatCompletions: true},
|
||||
"service_tier": {responses: true, chatCompletions: true},
|
||||
"structured_outputs": {chatCompletions: true},
|
||||
"strict_json_schema": {responses: true},
|
||||
// Web search fields configure tool wiring, not per-request provider
|
||||
// options.
|
||||
"web_search_enabled": {},
|
||||
"search_context_size": {},
|
||||
"allowed_domains": {},
|
||||
}
|
||||
|
||||
// service_tier uses "default" to cover a codersdk tier without a named fantasy
|
||||
// constant.
|
||||
var sampledOpenAIOptions = codersdk.ChatModelOpenAIProviderOptions{
|
||||
Include: []string{string(fantasyopenai.IncludeFileSearchCallResults)},
|
||||
Instructions: ptr("instructions"),
|
||||
LogitBias: map[string]int64{"50256": -10},
|
||||
LogProbs: ptr(true),
|
||||
TopLogProbs: ptr(int64(3)),
|
||||
MaxToolCalls: ptr(int64(8)),
|
||||
ParallelToolCalls: ptr(true),
|
||||
User: ptr("user-1"),
|
||||
ReasoningSummary: ptr("auto"),
|
||||
MaxCompletionTokens: ptr(int64(4096)),
|
||||
TextVerbosity: ptr("high"),
|
||||
Prediction: map[string]any{"type": "content"},
|
||||
Store: ptr(false),
|
||||
Metadata: map[string]any{"scope": "unit"},
|
||||
PromptCacheKey: ptr("cache-key"),
|
||||
SafetyIdentifier: ptr("safety-id"),
|
||||
ServiceTier: ptr("default"),
|
||||
StructuredOutputs: ptr(true),
|
||||
StrictJSONSchema: ptr(true),
|
||||
WebSearchEnabled: ptr(true),
|
||||
SearchContextSize: ptr("low"),
|
||||
AllowedDomains: []string{"example.com"},
|
||||
}
|
||||
|
||||
func TestProviderOptionsTransportParity(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
optionsType := reflect.TypeOf(codersdk.ChatModelOpenAIProviderOptions{})
|
||||
require.Len(t, openAIOptionTransportSupport, optionsType.NumField())
|
||||
|
||||
for i := 0; i < optionsType.NumField(); i++ {
|
||||
field := optionsType.Field(i)
|
||||
name, _, _ := strings.Cut(field.Tag.Get("json"), ",")
|
||||
want, ok := openAIOptionTransportSupport[name]
|
||||
require.Truef(t, ok, "field %s is missing from openAIOptionTransportSupport", name)
|
||||
|
||||
sample := reflect.ValueOf(sampledOpenAIOptions).Field(i)
|
||||
require.Falsef(t, sample.IsZero(), "field %s needs a sample value in sampledOpenAIOptions", name)
|
||||
|
||||
options := &codersdk.ChatModelOpenAIProviderOptions{}
|
||||
reflect.ValueOf(options).Elem().Field(i).Set(sample)
|
||||
|
||||
require.Equalf(t, want.responses,
|
||||
optionChangesConvertedOutput(t, ptr(true), options),
|
||||
"field %s on the Responses transport", name)
|
||||
require.Equalf(t, want.chatCompletions,
|
||||
optionChangesConvertedOutput(t, ptr(false), options),
|
||||
"field %s on the Chat Completions transport", name)
|
||||
}
|
||||
}
|
||||
|
||||
func optionChangesConvertedOutput(
|
||||
t *testing.T,
|
||||
responsesOverride *bool,
|
||||
options *codersdk.ChatModelOpenAIProviderOptions,
|
||||
) bool {
|
||||
t.Helper()
|
||||
model := fakeLanguageModel{provider: fantasyopenai.Name, model: "gpt-4.1"}
|
||||
baseline := chatopenai.ProviderOptionsFromChatConfig(
|
||||
model, &codersdk.ChatModelOpenAIProviderOptions{}, responsesOverride,
|
||||
)
|
||||
converted := chatopenai.ProviderOptionsFromChatConfig(model, options, responsesOverride)
|
||||
return !reflect.DeepEqual(baseline, converted)
|
||||
}
|
||||
@@ -122,12 +122,10 @@ func InlineImageCapBytes(provider string) (int, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// AcceptsFilePartMediaType reports whether provider accepts mediaType
|
||||
// as a file content part rather than silently dropping it. modelID
|
||||
// distinguishes API paths within a provider (e.g. OpenAI Responses vs
|
||||
// Chat Completions). Unknown providers return false so callers convert
|
||||
// text-family content to text and guarantee the model still sees it.
|
||||
func AcceptsFilePartMediaType(provider, modelID, mediaType string) bool {
|
||||
// AcceptsFilePartMediaType reports whether a provider transport accepts
|
||||
// mediaType as a native file part. Unknown providers return false so callers
|
||||
// can avoid silently dropping unsupported text-family content.
|
||||
func AcceptsFilePartMediaType(provider, modelID, mediaType string, openAIResponsesOverride *bool) bool {
|
||||
baseType := mediaType
|
||||
if parsed, _, err := mime.ParseMediaType(mediaType); err == nil {
|
||||
baseType = parsed
|
||||
@@ -140,7 +138,8 @@ func AcceptsFilePartMediaType(provider, modelID, mediaType string) bool {
|
||||
isAudio := baseType == "audio/wav" || baseType == "audio/mpeg" || baseType == "audio/mp3"
|
||||
isPDF := baseType == "application/pdf"
|
||||
|
||||
switch NormalizeProvider(provider) {
|
||||
normalized := NormalizeProvider(provider)
|
||||
switch normalized {
|
||||
case fantasygoogle.Name:
|
||||
// Google passes any file part through unfiltered.
|
||||
return true
|
||||
@@ -149,11 +148,8 @@ func AcceptsFilePartMediaType(provider, modelID, mediaType string) bool {
|
||||
// file-part acceptance, including text/* as native documents.
|
||||
return isImage || isText || isPDF
|
||||
case fantasyopenai.Name, fantasyazure.Name:
|
||||
// chatd configures both with WithUseResponsesAPI, but only
|
||||
// Responses-capable models actually use it. Non-Responses models
|
||||
// fall through to the Chat Completions path, which accepts
|
||||
// text/* and audio as native file parts (same as openaicompat).
|
||||
if fantasyopenai.IsResponsesModel(modelID) {
|
||||
// Chat Completions accepts text and audio as native file parts.
|
||||
if chatopenai.UsesResponsesAPI(normalized, modelID, openAIResponsesOverride) {
|
||||
return isImage || isPDF
|
||||
}
|
||||
return isImage || isText || isAudio || isPDF
|
||||
@@ -898,12 +894,24 @@ func BetaHeadersFromCallConfig(providerName string, config *codersdk.ChatModelCa
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAIResponsesAPIOverride returns the configured OpenAI Responses API
|
||||
// override, or nil when the model config leaves the choice to the provider
|
||||
// SDK's known-model list.
|
||||
func OpenAIResponsesAPIOverride(config *codersdk.ChatModelOpenAIConfig) *bool {
|
||||
if config == nil {
|
||||
return nil
|
||||
}
|
||||
return config.UseResponsesAPI
|
||||
}
|
||||
|
||||
// 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
|
||||
// API request. extraHeaders, when non-nil, are sent as additional
|
||||
// HTTP headers on every request. httpClient, when non-nil, is used for
|
||||
// all provider HTTP requests.
|
||||
// all provider HTTP requests. openAIResponsesOverride, when non-nil,
|
||||
// forces the OpenAI client onto the Responses API or Chat Completions
|
||||
// instead of deciding from the provider SDK's known-model list.
|
||||
func ModelFromConfig(
|
||||
providerHint string,
|
||||
modelName string,
|
||||
@@ -911,6 +919,7 @@ func ModelFromConfig(
|
||||
userAgent string,
|
||||
extraHeaders map[string]string,
|
||||
httpClient *http.Client,
|
||||
openAIResponsesOverride *bool,
|
||||
) (fantasy.LanguageModel, error) {
|
||||
provider, modelID, err := ResolveModelWithProviderHint(modelName, providerHint)
|
||||
if err != nil {
|
||||
@@ -999,6 +1008,12 @@ func ModelFromConfig(
|
||||
fantasyopenai.WithUseResponsesAPI(),
|
||||
fantasyopenai.WithUserAgent(userAgent),
|
||||
}
|
||||
if openAIResponsesOverride != nil {
|
||||
forced := *openAIResponsesOverride
|
||||
options = append(options, fantasyopenai.WithResponsesAPIFunc(func(string) bool {
|
||||
return forced
|
||||
}))
|
||||
}
|
||||
if len(extraHeaders) > 0 {
|
||||
options = append(options, fantasyopenai.WithHeaders(extraHeaders))
|
||||
}
|
||||
@@ -1099,6 +1114,7 @@ func missingProviderAPIKeyError(provider string) error {
|
||||
func ProviderOptionsFromChatModelConfig(
|
||||
model fantasy.LanguageModel,
|
||||
options *codersdk.ChatModelProviderOptions,
|
||||
openAIResponsesOverride *bool,
|
||||
) fantasy.ProviderOptions {
|
||||
if options == nil {
|
||||
return nil
|
||||
@@ -1110,6 +1126,7 @@ func ProviderOptionsFromChatModelConfig(
|
||||
result[fantasyopenai.Name] = chatopenai.ProviderOptionsFromChatConfig(
|
||||
model,
|
||||
options.OpenAI,
|
||||
openAIResponsesOverride,
|
||||
)
|
||||
}
|
||||
if options.Anthropic != nil {
|
||||
|
||||
@@ -394,7 +394,7 @@ func TestProviderOptionsFromChatModelConfig_AnthropicThinkingDisplay(t *testing.
|
||||
Anthropic: &codersdk.ChatModelAnthropicProviderOptions{
|
||||
ThinkingDisplay: ptr.Ref(" SUMMARIZED "),
|
||||
},
|
||||
})
|
||||
}, nil)
|
||||
|
||||
require.NotNil(t, providerOptions)
|
||||
anthropicOptions, ok := providerOptions[fantasyanthropic.Name].(*fantasyanthropic.ProviderOptions)
|
||||
@@ -915,6 +915,7 @@ func TestModelFromConfig_Bedrock(t *testing.T) {
|
||||
chatprovider.UserAgent(),
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, model)
|
||||
@@ -931,6 +932,7 @@ func TestModelFromConfig_Bedrock(t *testing.T) {
|
||||
chatprovider.UserAgent(),
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
require.Nil(t, model)
|
||||
require.EqualError(t, err, "API key for provider \"bedrock\" is not set")
|
||||
@@ -973,6 +975,7 @@ func TestModelFromConfig_Bedrock(t *testing.T) {
|
||||
chatprovider.UserAgent(),
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, model)
|
||||
@@ -1029,6 +1032,7 @@ func TestModelFromConfig_Bedrock(t *testing.T) {
|
||||
chatprovider.UserAgent(),
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
require.Nil(t, model)
|
||||
require.EqualError(t, err, tt.wantErr)
|
||||
@@ -1096,6 +1100,7 @@ func TestModelFromConfig_BedrockStripsAnthropicHeaders(t *testing.T) {
|
||||
chatprovider.UserAgent(),
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, model)
|
||||
@@ -1180,6 +1185,7 @@ func TestModelFromConfig_BedrockStreamingHeaders(t *testing.T) {
|
||||
chatprovider.UserAgent(),
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, model)
|
||||
@@ -1329,7 +1335,7 @@ func TestModelFromConfig_ExtraHeaders(t *testing.T) {
|
||||
BaseURLByProvider: map[string]string{"openai": serverURL},
|
||||
}
|
||||
|
||||
model, err := chatprovider.ModelFromConfig("openai", "gpt-4", keys, chatprovider.UserAgent(), headers, nil)
|
||||
model, err := chatprovider.ModelFromConfig("openai", "gpt-4", keys, chatprovider.UserAgent(), headers, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = model.Generate(ctx, fantasy.Call{
|
||||
@@ -1360,7 +1366,7 @@ func TestModelFromConfig_ExtraHeaders(t *testing.T) {
|
||||
BaseURLByProvider: map[string]string{"anthropic": serverURL},
|
||||
}
|
||||
|
||||
model, err := chatprovider.ModelFromConfig("anthropic", "claude-sonnet-4-20250514", keys, chatprovider.UserAgent(), headers, nil)
|
||||
model, err := chatprovider.ModelFromConfig("anthropic", "claude-sonnet-4-20250514", keys, chatprovider.UserAgent(), headers, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = model.Generate(ctx, fantasy.Call{
|
||||
@@ -1446,7 +1452,7 @@ func TestModelFromConfig_AnthropicBetaExtraHeader(t *testing.T) {
|
||||
betaHeaders := map[string]string{
|
||||
chatprovider.HeaderAnthropicBeta: chatprovider.AnthropicBetaContext1M,
|
||||
}
|
||||
model, err := chatprovider.ModelFromConfig(fantasyanthropic.Name, "claude-sonnet-4-20250514", keys, chatprovider.UserAgent(), betaHeaders, nil)
|
||||
model, err := chatprovider.ModelFromConfig(fantasyanthropic.Name, "claude-sonnet-4-20250514", keys, chatprovider.UserAgent(), betaHeaders, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, generateHello(ctx, model))
|
||||
@@ -1498,6 +1504,7 @@ func TestModelFromConfig_BedrockBetaExtraHeader(t *testing.T) {
|
||||
chatprovider.HeaderAnthropicBeta: chatprovider.AnthropicBetaContext1M,
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -1574,7 +1581,7 @@ func TestModelFromConfig_AnthropicPDFFilePartReachesProvider(t *testing.T) {
|
||||
BaseURLByProvider: map[string]string{"anthropic": serverURL},
|
||||
}
|
||||
|
||||
model, err := chatprovider.ModelFromConfig("anthropic", "claude-sonnet-4-20250514", keys, chatprovider.UserAgent(), nil, nil)
|
||||
model, err := chatprovider.ModelFromConfig("anthropic", "claude-sonnet-4-20250514", keys, chatprovider.UserAgent(), nil, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = model.Generate(ctx, fantasy.Call{
|
||||
@@ -1615,7 +1622,7 @@ func TestModelFromConfig_NilExtraHeaders(t *testing.T) {
|
||||
BaseURLByProvider: map[string]string{"openai": serverURL},
|
||||
}
|
||||
|
||||
model, err := chatprovider.ModelFromConfig("openai", "gpt-4", keys, chatprovider.UserAgent(), nil, nil)
|
||||
model, err := chatprovider.ModelFromConfig("openai", "gpt-4", keys, chatprovider.UserAgent(), nil, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = model.Generate(ctx, fantasy.Call{
|
||||
@@ -1659,6 +1666,7 @@ func TestModelFromConfig_HTTPClient(t *testing.T) {
|
||||
chatprovider.UserAgent(),
|
||||
nil,
|
||||
client,
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
@@ -14,77 +14,94 @@ func TestAcceptsFilePartMediaType(t *testing.T) {
|
||||
const responsesModel = "gpt-4o"
|
||||
const nonResponsesModel = "babbage-002"
|
||||
|
||||
forceResponses := true
|
||||
forceCompletions := false
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
provider string
|
||||
modelID string
|
||||
mediaType string
|
||||
want bool
|
||||
override *bool
|
||||
}{
|
||||
// OpenAI Responses accepts only images and PDFs.
|
||||
{"openai-json", "openai", responsesModel, "application/json", false},
|
||||
{"openai-text", "openai", responsesModel, "text/plain", false},
|
||||
{"openai-image", "openai", responsesModel, "image/png", true},
|
||||
{"openai-pdf", "openai", responsesModel, "application/pdf", true},
|
||||
{"openai-json", "openai", responsesModel, "application/json", false, nil},
|
||||
{"openai-text", "openai", responsesModel, "text/plain", false, nil},
|
||||
{"openai-image", "openai", responsesModel, "image/png", true, nil},
|
||||
{"openai-pdf", "openai", responsesModel, "application/pdf", true, nil},
|
||||
|
||||
// OpenAI Chat Completions (non-Responses models) accepts text/*
|
||||
// and audio as native file parts.
|
||||
{"openai-non-responses-text", "openai", nonResponsesModel, "text/plain", true},
|
||||
{"openai-non-responses-json", "openai", nonResponsesModel, "application/json", false},
|
||||
{"openai-non-responses-image", "openai", nonResponsesModel, "image/png", true},
|
||||
{"openai-non-responses-text", "openai", nonResponsesModel, "text/plain", true, nil},
|
||||
{"openai-non-responses-json", "openai", nonResponsesModel, "application/json", false, nil},
|
||||
{"openai-non-responses-image", "openai", nonResponsesModel, "image/png", true, nil},
|
||||
|
||||
// Azure uses the Responses API, same as OpenAI.
|
||||
{"azure-text", "azure", responsesModel, "text/markdown", false},
|
||||
{"azure-image", "azure", responsesModel, "image/jpeg", true},
|
||||
{"azure-text", "azure", responsesModel, "text/markdown", false, nil},
|
||||
{"azure-image", "azure", responsesModel, "image/jpeg", true, nil},
|
||||
|
||||
// Anthropic accepts text/* as native documents, but not JSON.
|
||||
{"anthropic-text", "anthropic", "", "text/markdown", true},
|
||||
{"anthropic-json", "anthropic", "", "application/json", false},
|
||||
{"anthropic-pdf", "anthropic", "", "application/pdf", true},
|
||||
{"anthropic-image", "anthropic", "", "image/webp", true},
|
||||
{"anthropic-text", "anthropic", "", "text/markdown", true, nil},
|
||||
{"anthropic-json", "anthropic", "", "application/json", false, nil},
|
||||
{"anthropic-pdf", "anthropic", "", "application/pdf", true, nil},
|
||||
{"anthropic-image", "anthropic", "", "image/webp", true, nil},
|
||||
|
||||
// Bedrock wraps Anthropic, so it matches Anthropic.
|
||||
{"bedrock-text", "bedrock", "", "text/csv", true},
|
||||
{"bedrock-json", "bedrock", "", "application/json", false},
|
||||
{"bedrock-text", "bedrock", "", "text/csv", true, nil},
|
||||
{"bedrock-json", "bedrock", "", "application/json", false, nil},
|
||||
|
||||
// OpenAI-compatible accepts text/*, images, audio, and PDFs.
|
||||
{"openaicompat-text", "openai-compat", "", "text/plain", true},
|
||||
{"openaicompat-json", "openai-compat", "", "application/json", false},
|
||||
{"openaicompat-audio", "openai-compat", "", "audio/mpeg", true},
|
||||
{"openaicompat-text", "openai-compat", "", "text/plain", true, nil},
|
||||
{"openaicompat-json", "openai-compat", "", "application/json", false, nil},
|
||||
{"openaicompat-audio", "openai-compat", "", "audio/mpeg", true, nil},
|
||||
|
||||
// OpenRouter and Vercel do not accept text file parts.
|
||||
{"openrouter-text", "openrouter", "", "text/plain", false},
|
||||
{"openrouter-json", "openrouter", "", "application/json", false},
|
||||
{"openrouter-image", "openrouter", "", "image/png", true},
|
||||
{"vercel-text", "vercel", "", "text/plain", false},
|
||||
{"vercel-json", "vercel", "", "application/json", false},
|
||||
{"vercel-pdf", "vercel", "", "application/pdf", true},
|
||||
{"openrouter-text", "openrouter", "", "text/plain", false, nil},
|
||||
{"openrouter-json", "openrouter", "", "application/json", false, nil},
|
||||
{"openrouter-image", "openrouter", "", "image/png", true, nil},
|
||||
{"vercel-text", "vercel", "", "text/plain", false, nil},
|
||||
{"vercel-json", "vercel", "", "application/json", false, nil},
|
||||
{"vercel-pdf", "vercel", "", "application/pdf", true, nil},
|
||||
|
||||
// Google passes all file parts through unfiltered.
|
||||
{"google-json", "google", "", "application/json", true},
|
||||
{"google-text", "google", "", "text/plain", true},
|
||||
{"google-anything", "google", "", "application/octet-stream", true},
|
||||
{"google-json", "google", "", "application/json", true, nil},
|
||||
{"google-text", "google", "", "text/plain", true, nil},
|
||||
{"google-anything", "google", "", "application/octet-stream", true, nil},
|
||||
|
||||
// Unknown providers reject everything so text-family content is
|
||||
// converted to text and still reaches the model.
|
||||
{"unknown-text", "made-up-provider", "", "text/plain", false},
|
||||
{"empty-text", "", "", "text/plain", false},
|
||||
{"unknown-text", "made-up-provider", "", "text/plain", false, nil},
|
||||
{"empty-text", "", "", "text/plain", false, nil},
|
||||
|
||||
// Base media type handling: parameters are stripped.
|
||||
{"anthropic-text-charset", "anthropic", "", "text/plain; charset=utf-8", true},
|
||||
{"openai-text-charset", "openai", responsesModel, "text/plain; charset=utf-8", false},
|
||||
{"anthropic-text-charset", "anthropic", "", "text/plain; charset=utf-8", true, nil},
|
||||
{"openai-text-charset", "openai", responsesModel, "text/plain; charset=utf-8", false, nil},
|
||||
|
||||
// Provider name normalization is case-insensitive.
|
||||
{"anthropic-uppercase", "Anthropic", "", "text/plain", true},
|
||||
{"anthropic-uppercase", "Anthropic", "", "text/plain", true, nil},
|
||||
|
||||
{"openai-forced-responses-text", "openai", nonResponsesModel, "text/plain", false, &forceResponses},
|
||||
{"openai-forced-responses-image", "openai", nonResponsesModel, "image/png", true, &forceResponses},
|
||||
{"openai-forced-completions-text", "openai", responsesModel, "text/plain", true, &forceCompletions},
|
||||
{"openai-forced-completions-audio", "openai", responsesModel, "audio/mpeg", true, &forceCompletions},
|
||||
|
||||
// Azure has no equivalent provider option, so it keeps using the
|
||||
// known-model list even when a config sets the override.
|
||||
{"azure-ignores-forced-completions", "azure", responsesModel, "text/plain", false, &forceCompletions},
|
||||
{"azure-ignores-forced-responses", "azure", nonResponsesModel, "text/plain", true, &forceResponses},
|
||||
|
||||
{"anthropic-ignores-override", "anthropic", "", "text/plain", true, &forceResponses},
|
||||
{"openaicompat-ignores-override", "openai-compat", "", "text/plain", true, &forceResponses},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := chatprovider.AcceptsFilePartMediaType(tc.provider, tc.modelID, tc.mediaType)
|
||||
got := chatprovider.AcceptsFilePartMediaType(tc.provider, tc.modelID, tc.mediaType, tc.override)
|
||||
if got != tc.want {
|
||||
t.Fatalf("AcceptsFilePartMediaType(%q, %q, %q) = %v, want %v",
|
||||
tc.provider, tc.modelID, tc.mediaType, got, tc.want)
|
||||
t.Fatalf("AcceptsFilePartMediaType(%q, %q, %q, %v) = %v, want %v",
|
||||
tc.provider, tc.modelID, tc.mediaType, tc.override, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ func generateOpenAICompatRequest(t *testing.T, baseURL string, modelID string) m
|
||||
chatprovider.UserAgent(),
|
||||
nil,
|
||||
&http.Client{Transport: transport},
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@ func ApplyReasoningEffort(
|
||||
model fantasy.LanguageModel,
|
||||
options fantasy.ProviderOptions,
|
||||
effort *string,
|
||||
openAIResponsesOverride *bool,
|
||||
) fantasy.ProviderOptions {
|
||||
if effort == nil || model == nil {
|
||||
return options
|
||||
@@ -109,7 +110,7 @@ func ApplyReasoningEffort(
|
||||
case *fantasyopenai.ProviderOptions:
|
||||
opts.ReasoningEffort = &providerEffort
|
||||
default:
|
||||
if chatopenai.UsesResponsesOptions(model) {
|
||||
if chatopenai.UsesResponsesOptions(model, openAIResponsesOverride) {
|
||||
options[fantasyopenai.Name] = &fantasyopenai.ResponsesProviderOptions{
|
||||
ReasoningEffort: &providerEffort,
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ func TestApplyReasoningEffort(t *testing.T) {
|
||||
t.Run("CreatesOpenAIResponsesEntry", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := chatprovider.ApplyReasoningEffort(&chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: "gpt-5"}, nil, new(codersdk.ChatModelReasoningEffortHigh))
|
||||
got := chatprovider.ApplyReasoningEffort(&chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: "gpt-5"}, nil, new(codersdk.ChatModelReasoningEffortHigh), nil)
|
||||
providerOptions, ok := got[fantasyopenai.Name].(*fantasyopenai.ResponsesProviderOptions)
|
||||
require.True(t, ok, "%T", got[fantasyopenai.Name])
|
||||
require.NotNil(t, providerOptions.ReasoningEffort)
|
||||
@@ -101,7 +101,7 @@ func TestApplyReasoningEffort(t *testing.T) {
|
||||
Store: ptr.Ref(true),
|
||||
},
|
||||
}
|
||||
got := chatprovider.ApplyReasoningEffort(&chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: "gpt-5"}, options, new(codersdk.ChatModelReasoningEffortHigh))
|
||||
got := chatprovider.ApplyReasoningEffort(&chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: "gpt-5"}, options, new(codersdk.ChatModelReasoningEffortHigh), nil)
|
||||
providerOptions, ok := got[fantasyopenai.Name].(*fantasyopenai.ResponsesProviderOptions)
|
||||
require.True(t, ok, "%T", got[fantasyopenai.Name])
|
||||
require.Same(t, options[fantasyopenai.Name], providerOptions)
|
||||
@@ -119,7 +119,7 @@ func TestApplyReasoningEffort(t *testing.T) {
|
||||
ParallelToolCalls: ptr.Ref(true),
|
||||
},
|
||||
}
|
||||
got := chatprovider.ApplyReasoningEffort(&chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: "gpt-4"}, options, new(codersdk.ChatModelReasoningEffortHigh))
|
||||
got := chatprovider.ApplyReasoningEffort(&chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: "gpt-4"}, options, new(codersdk.ChatModelReasoningEffortHigh), nil)
|
||||
providerOptions, ok := got[fantasyopenai.Name].(*fantasyopenai.ProviderOptions)
|
||||
require.True(t, ok, "%T", got[fantasyopenai.Name])
|
||||
require.Same(t, options[fantasyopenai.Name], providerOptions)
|
||||
@@ -225,7 +225,7 @@ func TestApplyReasoningEffort(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := chatprovider.ApplyReasoningEffort(&chattest.FakeModel{ProviderName: tt.provider}, tt.options, new(codersdk.ChatModelReasoningEffortHigh))
|
||||
got := chatprovider.ApplyReasoningEffort(&chattest.FakeModel{ProviderName: tt.provider}, tt.options, new(codersdk.ChatModelReasoningEffortHigh), nil)
|
||||
tt.assert(t, got)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package chatprovider_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
fantasyopenai "charm.land/fantasy/providers/openai"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatprovider"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chattest"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
func TestModelFromConfig_OpenAIResponsesAPIOverride(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Taken from opposite sides of the provider SDK's known-model list.
|
||||
const responsesModel = "gpt-4o"
|
||||
const nonResponsesModel = "babbage-002"
|
||||
|
||||
forceResponses := true
|
||||
forceCompletions := false
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
model string
|
||||
override *bool
|
||||
wantPath string
|
||||
}{
|
||||
{"DefaultKnownModel", responsesModel, nil, "/responses"},
|
||||
{"DefaultUnknownModel", nonResponsesModel, nil, "/chat/completions"},
|
||||
{"ForceResponsesOnUnknownModel", nonResponsesModel, &forceResponses, "/responses"},
|
||||
{"ForceCompletionsOnKnownModel", responsesModel, &forceCompletions, "/chat/completions"},
|
||||
{"ForceResponsesOnKnownModel", responsesModel, &forceResponses, "/responses"},
|
||||
{"ForceCompletionsOnUnknownModel", nonResponsesModel, &forceCompletions, "/chat/completions"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var mu sync.Mutex
|
||||
var gotPath string
|
||||
serverURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
|
||||
mu.Lock()
|
||||
gotPath = req.Request.URL.Path
|
||||
mu.Unlock()
|
||||
return chattest.OpenAINonStreamingResponse("ok")
|
||||
})
|
||||
|
||||
model, err := chatprovider.ModelFromConfig(
|
||||
fantasyopenai.Name,
|
||||
tc.model,
|
||||
chatprovider.ProviderAPIKeys{
|
||||
ByProvider: map[string]string{fantasyopenai.Name: "test-key"},
|
||||
BaseURLByProvider: map[string]string{fantasyopenai.Name: serverURL},
|
||||
},
|
||||
chatprovider.UserAgent(),
|
||||
nil,
|
||||
nil,
|
||||
tc.override,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = model.Generate(context.Background(), fantasy.Call{
|
||||
Prompt: []fantasy.Message{{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{fantasy.TextPart{Text: "Test message"}},
|
||||
}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
require.Equal(t, tc.wantPath, gotPath)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesAPIOverride(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
useResponsesAPI := true
|
||||
|
||||
t.Run("NilConfig", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Nil(t, chatprovider.OpenAIResponsesAPIOverride(nil))
|
||||
})
|
||||
|
||||
t.Run("Unset", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Nil(t, chatprovider.OpenAIResponsesAPIOverride(&codersdk.ChatModelOpenAIConfig{}))
|
||||
})
|
||||
|
||||
t.Run("Set", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := chatprovider.OpenAIResponsesAPIOverride(&codersdk.ChatModelOpenAIConfig{
|
||||
UseResponsesAPI: &useResponsesAPI,
|
||||
})
|
||||
require.NotNil(t, got)
|
||||
require.True(t, *got)
|
||||
})
|
||||
}
|
||||
|
||||
// When other OpenAI options exist, the override must select the struct type
|
||||
// the chosen API reads.
|
||||
func TestProviderOptionsFromChatModelConfig_ResponsesAPIOverride(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const responsesModel = "gpt-4o"
|
||||
const nonResponsesModel = "babbage-002"
|
||||
|
||||
forceResponses := true
|
||||
forceCompletions := false
|
||||
serviceTier := "auto"
|
||||
|
||||
t.Run("ForceResponsesUsesResponsesOptions", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := chatprovider.ProviderOptionsFromChatModelConfig(
|
||||
&chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: nonResponsesModel},
|
||||
&codersdk.ChatModelProviderOptions{
|
||||
OpenAI: &codersdk.ChatModelOpenAIProviderOptions{ServiceTier: &serviceTier},
|
||||
},
|
||||
&forceResponses,
|
||||
)
|
||||
require.IsType(t, &fantasyopenai.ResponsesProviderOptions{}, got[fantasyopenai.Name])
|
||||
})
|
||||
|
||||
t.Run("ForceCompletionsUsesCompletionsOptions", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := chatprovider.ProviderOptionsFromChatModelConfig(
|
||||
&chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: responsesModel},
|
||||
&codersdk.ChatModelProviderOptions{
|
||||
OpenAI: &codersdk.ChatModelOpenAIProviderOptions{ServiceTier: &serviceTier},
|
||||
},
|
||||
&forceCompletions,
|
||||
)
|
||||
require.IsType(t, &fantasyopenai.ProviderOptions{}, got[fantasyopenai.Name])
|
||||
})
|
||||
}
|
||||
|
||||
// When the override is the only OpenAI option, ApplyReasoningEffort creates
|
||||
// the provider options itself and must match the chosen API.
|
||||
func TestApplyReasoningEffort_ResponsesAPIOverride(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
forceResponses := true
|
||||
forceCompletions := false
|
||||
|
||||
t.Run("ForceResponsesOnUnknownModel", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := chatprovider.ApplyReasoningEffort(
|
||||
&chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: "babbage-002"},
|
||||
nil,
|
||||
new(codersdk.ChatModelReasoningEffortHigh),
|
||||
&forceResponses,
|
||||
)
|
||||
require.IsType(t, &fantasyopenai.ResponsesProviderOptions{}, got[fantasyopenai.Name])
|
||||
})
|
||||
|
||||
t.Run("ForceCompletionsOnKnownModel", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := chatprovider.ApplyReasoningEffort(
|
||||
&chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: "gpt-4o"},
|
||||
nil,
|
||||
new(codersdk.ChatModelReasoningEffortHigh),
|
||||
&forceCompletions,
|
||||
)
|
||||
require.IsType(t, &fantasyopenai.ProviderOptions{}, got[fantasyopenai.Name])
|
||||
})
|
||||
}
|
||||
@@ -48,7 +48,7 @@ func TestModelFromConfig_UserAgent(t *testing.T) {
|
||||
BaseURLByProvider: map[string]string{"openai": serverURL},
|
||||
}
|
||||
|
||||
model, err := chatprovider.ModelFromConfig("openai", "gpt-4", keys, expectedUA, nil, nil)
|
||||
model, err := chatprovider.ModelFromConfig("openai", "gpt-4", keys, expectedUA, nil, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Make a real call so Fantasy sends an HTTP request to the
|
||||
|
||||
@@ -42,6 +42,9 @@ type compactionModelOverride struct {
|
||||
// providerOptions include the override's reasoning effort for the
|
||||
// summary call.
|
||||
providerOptions fantasy.ProviderOptions
|
||||
// openAIResponsesOverride keeps prompt sanitization aligned with the
|
||||
// API the override model client was built for.
|
||||
openAIResponsesOverride *bool
|
||||
}
|
||||
|
||||
// resolvedCompactionOverride is the compaction override resolved at
|
||||
@@ -136,10 +139,11 @@ func (p *Server) buildCompactionOverrideModel(
|
||||
)
|
||||
}
|
||||
model, _, err := p.newDebugAwareModel(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 compactionModelOverride{}, xerrors.Errorf(
|
||||
@@ -147,16 +151,17 @@ func (p *Server) buildCompactionOverrideModel(
|
||||
err,
|
||||
)
|
||||
}
|
||||
providerOptions, err := compactionOverrideProviderOptions(model, modelConfig)
|
||||
providerOptions, responsesOverride, err := compactionOverrideProviderOptions(model, modelConfig)
|
||||
if err != nil {
|
||||
return compactionModelOverride{}, err
|
||||
}
|
||||
return compactionModelOverride{
|
||||
modelConfig: modelConfig,
|
||||
model: model,
|
||||
resolvedProvider: resolvedProvider,
|
||||
resolvedModel: resolvedModel,
|
||||
providerOptions: providerOptions,
|
||||
modelConfig: modelConfig,
|
||||
model: model,
|
||||
resolvedProvider: resolvedProvider,
|
||||
resolvedModel: resolvedModel,
|
||||
providerOptions: providerOptions,
|
||||
openAIResponsesOverride: responsesOverride,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -166,23 +171,30 @@ func (p *Server) buildCompactionOverrideModel(
|
||||
func compactionOverrideProviderOptions(
|
||||
model fantasy.LanguageModel,
|
||||
modelConfig database.ChatModelConfig,
|
||||
) (fantasy.ProviderOptions, error) {
|
||||
) (fantasy.ProviderOptions, *bool, error) {
|
||||
callConfig := codersdk.ChatModelCallConfig{}
|
||||
if len(modelConfig.Options) > 0 {
|
||||
if err := json.Unmarshal(modelConfig.Options, &callConfig); err != nil {
|
||||
return nil, xerrors.Errorf(
|
||||
return nil, nil, xerrors.Errorf(
|
||||
"parse compaction model override call config: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
responsesOverride := chatprovider.OpenAIResponsesAPIOverride(callConfig.OpenAIConfig)
|
||||
providerOptions := chatprovider.ProviderOptionsFromChatModelConfig(
|
||||
model,
|
||||
callConfig.ProviderOptions,
|
||||
responsesOverride,
|
||||
)
|
||||
reasoningEffort := chatprovider.ResolveReasoningEffort(
|
||||
nil,
|
||||
callConfig.ReasoningEffort,
|
||||
)
|
||||
return chatprovider.ApplyReasoningEffort(model, providerOptions, reasoningEffort), nil
|
||||
return chatprovider.ApplyReasoningEffort(
|
||||
model,
|
||||
providerOptions,
|
||||
reasoningEffort,
|
||||
responsesOverride,
|
||||
), responsesOverride, nil
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestCompactionOverrideProviderOptions(t *testing.T) {
|
||||
|
||||
t.Run("NoOptions", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
opts, err := compactionOverrideProviderOptions(model, database.ChatModelConfig{})
|
||||
opts, _, err := compactionOverrideProviderOptions(model, database.ChatModelConfig{})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, opts)
|
||||
})
|
||||
@@ -40,7 +40,7 @@ func TestCompactionOverrideProviderOptions(t *testing.T) {
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
opts, err := compactionOverrideProviderOptions(model, database.ChatModelConfig{Options: options})
|
||||
opts, _, err := compactionOverrideProviderOptions(model, database.ChatModelConfig{Options: options})
|
||||
require.NoError(t, err)
|
||||
anthropicOpts, ok := opts[fantasyanthropic.Name].(*fantasyanthropic.ProviderOptions)
|
||||
require.True(t, ok)
|
||||
@@ -50,7 +50,7 @@ func TestCompactionOverrideProviderOptions(t *testing.T) {
|
||||
|
||||
t.Run("MalformedOptions", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := compactionOverrideProviderOptions(model, database.ChatModelConfig{Options: []byte("{")})
|
||||
_, _, err := compactionOverrideProviderOptions(model, database.ChatModelConfig{Options: []byte("{")})
|
||||
require.ErrorContains(t, err, "parse compaction model override call config")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ func sanitizeCompactionPrompt(
|
||||
compactionModel fantasy.LanguageModel,
|
||||
chatConfig database.ChatModelConfig,
|
||||
overrideConfig database.ChatModelConfig,
|
||||
openAIResponsesOverride *bool,
|
||||
) []fantasy.Message {
|
||||
messages := prompt
|
||||
if !sameCompactionProviderIdentity(chatConfig, overrideConfig) {
|
||||
@@ -40,6 +41,7 @@ func sanitizeCompactionPrompt(
|
||||
compactionModel.Provider(),
|
||||
compactionModel.Model(),
|
||||
mediaType,
|
||||
openAIResponsesOverride,
|
||||
)
|
||||
})
|
||||
sanitized, stats := chatsanitize.SanitizeAnthropicProviderToolHistory(
|
||||
|
||||
@@ -71,7 +71,7 @@ func TestSanitizeCompactionPrompt_FlattensForeignProviderExecutedToolParts(t *te
|
||||
}
|
||||
|
||||
compactionModel := &chattest.FakeModel{ProviderName: "openai", ModelName: "gpt-4.1-mini"}
|
||||
sanitized := sanitizeCompactionPrompt(ctx, logger, prompt, compactionModel, configWithProvider(uuid.New()), configWithProvider(uuid.New()))
|
||||
sanitized := sanitizeCompactionPrompt(ctx, logger, prompt, compactionModel, configWithProvider(uuid.New()), configWithProvider(uuid.New()), nil)
|
||||
|
||||
require.Len(t, sanitized, 3)
|
||||
// Provider-executed parts are flattened to text so the summary keeps
|
||||
@@ -120,7 +120,7 @@ func TestSanitizeCompactionPrompt_DropsNonAssistantProviderExecutedParts(t *test
|
||||
}
|
||||
|
||||
compactionModel := &chattest.FakeModel{ProviderName: "openai", ModelName: "gpt-4.1-mini"}
|
||||
sanitized := sanitizeCompactionPrompt(ctx, logger, prompt, compactionModel, configWithProvider(uuid.New()), configWithProvider(uuid.New()))
|
||||
sanitized := sanitizeCompactionPrompt(ctx, logger, prompt, compactionModel, configWithProvider(uuid.New()), configWithProvider(uuid.New()), nil)
|
||||
|
||||
require.Len(t, sanitized, 1)
|
||||
require.Equal(t, fantasy.MessageRoleUser, sanitized[0].Role)
|
||||
@@ -150,7 +150,7 @@ func TestSanitizeCompactionPrompt_ReplacesUnsupportedFileParts(t *testing.T) {
|
||||
// placeholder while the prompt stays otherwise intact.
|
||||
compactionModel := &chattest.FakeModel{ProviderName: "mistral", ModelName: "mistral-large"}
|
||||
sharedProviderID := uuid.New()
|
||||
sanitized := sanitizeCompactionPrompt(ctx, logger, prompt, compactionModel, configWithProvider(sharedProviderID), configWithProvider(sharedProviderID))
|
||||
sanitized := sanitizeCompactionPrompt(ctx, logger, prompt, compactionModel, configWithProvider(sharedProviderID), configWithProvider(sharedProviderID), nil)
|
||||
|
||||
require.Len(t, sanitized, 1)
|
||||
require.Len(t, sanitized[0].Content, 2)
|
||||
@@ -190,7 +190,7 @@ func TestSanitizeCompactionPrompt_SameProviderKeepsProviderExecutedParts(t *test
|
||||
|
||||
compactionModel := &chattest.FakeModel{ProviderName: "openai", ModelName: "gpt-4.1-mini"}
|
||||
sharedProviderID := uuid.New()
|
||||
sanitized := sanitizeCompactionPrompt(ctx, logger, prompt, compactionModel, configWithProvider(sharedProviderID), configWithProvider(sharedProviderID))
|
||||
sanitized := sanitizeCompactionPrompt(ctx, logger, prompt, compactionModel, configWithProvider(sharedProviderID), configWithProvider(sharedProviderID), nil)
|
||||
|
||||
require.Len(t, sanitized, 1)
|
||||
require.Len(t, sanitized[0].Content, 2)
|
||||
|
||||
@@ -939,6 +939,7 @@ func (s *taskStarter) generateCompaction(
|
||||
overrideModel.model,
|
||||
prepared.Compaction.ChatModelConfig,
|
||||
overrideModel.modelConfig,
|
||||
overrideModel.openAIResponsesOverride,
|
||||
)
|
||||
}
|
||||
preResult, err := s.server.hooks.Trigger(ctx, chathooks.ChatFor(prepared.Chat, input.hookTurnID()), chathooks.Message{}, agenthooks.EventPreCompact, dispatch.CapacityClassGeneration)
|
||||
|
||||
@@ -101,6 +101,45 @@ func (server *Server) prepareGeneration(
|
||||
callConfig.MaxOutputTokens = &maxOutputTokens
|
||||
}
|
||||
|
||||
// Computer-use turns swap in a specialized model, so the substitution
|
||||
// must happen before anything model-sensitive runs: file-part
|
||||
// classification, history sanitization, and provider option preparation
|
||||
// must all agree with the client actually used for the turn.
|
||||
isComputerUse := chat.Mode.Valid && chat.Mode.ChatMode == database.ChatModeComputerUse
|
||||
var computerUseProvider codersdk.ChatComputerUseProvider
|
||||
if isComputerUse {
|
||||
var cuModelProvider, cuModelName string
|
||||
computerUseProvider, cuModelProvider, cuModelName, err = server.computerUseProviderAndModelFromConfig(ctx)
|
||||
if err != nil {
|
||||
return generationPrepared{}, xerrors.Errorf("resolve computer use provider and model: %w", err)
|
||||
}
|
||||
computerUseRoute, keyErr := server.resolveModelRouteForProviderType(ctx, chat.OwnerID, cuModelProvider)
|
||||
if keyErr != nil {
|
||||
return generationPrepared{}, xerrors.Errorf("resolve computer use provider route: %w", keyErr)
|
||||
}
|
||||
modelRoute = computerUseRoute
|
||||
cuModel, cuDebugEnabled, cuResolvedProvider, cuResolvedModel, cuErr := server.resolveComputerUseModel(
|
||||
ctx,
|
||||
chat,
|
||||
computerUseRoute,
|
||||
computerUseProvider,
|
||||
cuModelProvider,
|
||||
cuModelName,
|
||||
modelOpts,
|
||||
)
|
||||
if cuErr != nil {
|
||||
return generationPrepared{}, cuErr
|
||||
}
|
||||
model = cuModel
|
||||
debugEnabled = cuDebugEnabled
|
||||
resolvedProvider = cuResolvedProvider
|
||||
debugModel = cuResolvedModel
|
||||
// The computer-use client is built without ConfigOptions, so the
|
||||
// chat model's transport override must not follow the substituted
|
||||
// model into provider option preparation.
|
||||
callConfig.OpenAIConfig = nil
|
||||
}
|
||||
|
||||
currentPlanMode := chat.PlanMode
|
||||
isPlanModeTurn := currentPlanMode.Valid && currentPlanMode.ChatPlanMode == database.ChatPlanModePlan
|
||||
isExploreSubagent := isExploreSubagentMode(chat.Mode)
|
||||
@@ -259,7 +298,12 @@ func (server *Server) prepareGeneration(
|
||||
// Anthropic transport). The conversion that actually drops or
|
||||
// accepts a file part is the one for model.Provider().
|
||||
acceptsFilePart := func(mediaType string) bool {
|
||||
return chatprovider.AcceptsFilePartMediaType(model.Provider(), model.Model(), mediaType)
|
||||
return chatprovider.AcceptsFilePartMediaType(
|
||||
model.Provider(),
|
||||
model.Model(),
|
||||
mediaType,
|
||||
chatprovider.OpenAIResponsesAPIOverride(callConfig.OpenAIConfig),
|
||||
)
|
||||
}
|
||||
providerType := string(modelRoute.Provider.Type)
|
||||
prompt, err = chatprompt.ConvertMessagesWithFiles(ctx, promptRows, server.chatFileResolver(providerType), logger, acceptsFilePart)
|
||||
@@ -480,36 +524,7 @@ func (server *Server) prepareGeneration(
|
||||
}
|
||||
}
|
||||
|
||||
isComputerUse := chat.Mode.Valid && chat.Mode.ChatMode == database.ChatModeComputerUse
|
||||
if isComputerUse {
|
||||
computerUseProvider, computerUseModelProvider, computerUseModelName, err := server.computerUseProviderAndModelFromConfig(ctx)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return generationPrepared{}, xerrors.Errorf("resolve computer use provider and model: %w", err)
|
||||
}
|
||||
computerUseRoute, keyErr := server.resolveModelRouteForProviderType(ctx, chat.OwnerID, computerUseModelProvider)
|
||||
if keyErr != nil {
|
||||
cleanup()
|
||||
return generationPrepared{}, xerrors.Errorf("resolve computer use provider route: %w", keyErr)
|
||||
}
|
||||
modelRoute = computerUseRoute
|
||||
cuModel, cuDebugEnabled, cuResolvedProvider, cuResolvedModel, cuErr := server.resolveComputerUseModel(
|
||||
ctx,
|
||||
chat,
|
||||
computerUseRoute,
|
||||
computerUseProvider,
|
||||
computerUseModelProvider,
|
||||
computerUseModelName,
|
||||
modelOpts,
|
||||
)
|
||||
if cuErr != nil {
|
||||
cleanup()
|
||||
return generationPrepared{}, cuErr
|
||||
}
|
||||
model = cuModel
|
||||
debugEnabled = cuDebugEnabled
|
||||
resolvedProvider = cuResolvedProvider
|
||||
debugModel = cuResolvedModel
|
||||
providerTools, err = appendComputerUseProviderTool(providerTools, computerUseProviderToolOptions{
|
||||
provider: computerUseProvider,
|
||||
isPlanModeTurn: isPlanModeTurn,
|
||||
@@ -542,8 +557,18 @@ func (server *Server) prepareGeneration(
|
||||
requestedEffort,
|
||||
callConfig.ReasoningEffort,
|
||||
)
|
||||
providerOptions := chatprovider.ProviderOptionsFromChatModelConfig(model, callConfig.ProviderOptions)
|
||||
providerOptions = chatprovider.ApplyReasoningEffort(model, providerOptions, reasoningEffort)
|
||||
responsesOverride := chatprovider.OpenAIResponsesAPIOverride(callConfig.OpenAIConfig)
|
||||
providerOptions := chatprovider.ProviderOptionsFromChatModelConfig(
|
||||
model,
|
||||
callConfig.ProviderOptions,
|
||||
responsesOverride,
|
||||
)
|
||||
providerOptions = chatprovider.ApplyReasoningEffort(
|
||||
model,
|
||||
providerOptions,
|
||||
reasoningEffort,
|
||||
responsesOverride,
|
||||
)
|
||||
|
||||
activeToolNames := activeToolNamesForTurn(tools, currentPlanMode, chat.ParentChatID, approvedPlanMCPConfigIDs)
|
||||
if isExploreSubagent {
|
||||
@@ -768,7 +793,7 @@ func (server *Server) deriveFinalTurnRunResult(
|
||||
return runChatResult{FinalAssistantText: finalAssistantText, TriggerMessageID: triggerMessageID, HistoryTipMessageID: historyTipMessageID}
|
||||
}
|
||||
modelOpts := modelBuildOptions{ActiveAPIKeyID: apiKeyID}
|
||||
model, _, modelRoute, _, resolvedProvider, resolvedModel, err := server.resolveChatModel(ctx, chat, modelOpts)
|
||||
model, dbConfig, modelRoute, _, resolvedProvider, resolvedModel, err := server.resolveChatModel(ctx, chat, modelOpts)
|
||||
if err != nil {
|
||||
// Return what we have; generateFinalTurnStatusLabel falls back to a
|
||||
// generic label when StatusLabelModel is nil.
|
||||
@@ -787,6 +812,7 @@ func (server *Server) deriveFinalTurnRunResult(
|
||||
FallbackRoute: modelRoute,
|
||||
FallbackModel: resolvedModel,
|
||||
ModelBuildOptions: modelOpts,
|
||||
StatusLabelOptions: dbConfig.Options,
|
||||
TriggerMessageID: triggerMessageID,
|
||||
HistoryTipMessageID: historyTipMessageID,
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ package chatd //nolint:testpackage // Exercises unexported re-derivation helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
fantasyopenai "charm.land/fantasy/providers/openai"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sqlc-dev/pqtype"
|
||||
@@ -158,6 +160,114 @@ func TestPrepareGenerationClampsRequestedReasoningEffortToMax(t *testing.T) {
|
||||
require.Equal(t, fantasyopenai.ReasoningEffortMedium, *providerOptions.ReasoningEffort)
|
||||
}
|
||||
|
||||
func TestPrepareGenerationComputerUseIgnoresChatTransportOverride(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
ctx := chatdTestContext(t)
|
||||
user := dbgen.User(t, db, database.User{})
|
||||
org := dbgen.Organization(t, db, database.Organization{})
|
||||
dbgen.OrganizationMember(t, db, database.OrganizationMember{
|
||||
UserID: user.ID,
|
||||
OrganizationID: org.ID,
|
||||
})
|
||||
require.NoError(t, db.UpsertChatComputerUseProvider(ctx, string(codersdk.ChatComputerUseProviderOpenAI)))
|
||||
provider := dbgen.AIProviderWithOptionalKey(t, db, database.AIProvider{
|
||||
Type: database.AIProviderTypeOpenai,
|
||||
}, "test-key")
|
||||
forceCompletions := false
|
||||
modelConfigRaw, err := json.Marshal(codersdk.ChatModelCallConfig{
|
||||
OpenAIConfig: &codersdk.ChatModelOpenAIConfig{
|
||||
UseResponsesAPI: &forceCompletions,
|
||||
},
|
||||
ProviderOptions: &codersdk.ChatModelProviderOptions{
|
||||
OpenAI: &codersdk.ChatModelOpenAIProviderOptions{
|
||||
User: ptr.Ref("computer-use"),
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
modelConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
|
||||
Model: "gpt-4o-mini",
|
||||
Options: modelConfigRaw,
|
||||
AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true},
|
||||
}, func(p *database.InsertChatModelConfigParams) {
|
||||
p.Enabled = true
|
||||
})
|
||||
|
||||
const attachmentText = "text attachment body"
|
||||
file, err := db.InsertChatFile(ctx, database.InsertChatFileParams{
|
||||
OwnerID: user.ID,
|
||||
OrganizationID: org.ID,
|
||||
Name: "notes.txt",
|
||||
Mimetype: "text/plain",
|
||||
Data: []byte(attachmentText),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
content, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{
|
||||
codersdk.ChatMessageText("hello"),
|
||||
codersdk.ChatMessageFile(file.ID, "text/plain", "notes.txt"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
created, err := chatstate.CreateChat(ctx, db, ps, chatstate.CreateChatInput{
|
||||
OrganizationID: org.ID,
|
||||
OwnerID: user.ID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "computer use transport",
|
||||
ClientType: database.ChatClientTypeApi,
|
||||
Mode: database.NullChatMode{ChatMode: database.ChatModeComputerUse, Valid: true},
|
||||
InitialMessages: []chatstate.Message{
|
||||
{
|
||||
Role: database.ChatMessageRoleUser,
|
||||
Content: content,
|
||||
Visibility: database.ChatMessageVisibilityBoth,
|
||||
ModelConfigID: uuid.NullUUID{UUID: modelConfig.ID, Valid: true},
|
||||
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
|
||||
ContentVersion: chatprompt.CurrentContentVersion,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
server := newInternalTestServer(
|
||||
t,
|
||||
db,
|
||||
ps,
|
||||
chatprovider.ProviderAPIKeys{},
|
||||
withInternalTestServerTransportFactory(&aibridgeTestFactory{}),
|
||||
)
|
||||
prepared, err := server.prepareGeneration(ctx, generationPrepareInput{
|
||||
Chat: created.Chat,
|
||||
Messages: created.InitialMessages,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(prepared.Cleanup)
|
||||
|
||||
// The computer-use model (gpt-5.5) is Responses-selected by the SDK and
|
||||
// its client ignores the config's forced Chat Completions, so the
|
||||
// options must be the Responses type or the SDK discards them.
|
||||
_, ok := prepared.ProviderOptions[fantasyopenai.Name].(*fantasyopenai.ResponsesProviderOptions)
|
||||
require.True(t, ok, "%T", prepared.ProviderOptions[fantasyopenai.Name])
|
||||
|
||||
// File classification must also key on the substituted model: the
|
||||
// Responses transport drops native text file parts, so the attachment
|
||||
// must be inlined as text rather than kept as a FilePart.
|
||||
var sawInlinedText bool
|
||||
for _, message := range prepared.Prompt {
|
||||
for _, part := range message.Content {
|
||||
if filePart, isFile := part.(fantasy.FilePart); isFile {
|
||||
t.Fatalf("text attachment survived as FilePart %q", filePart.Filename)
|
||||
}
|
||||
if textPart, isText := part.(fantasy.TextPart); isText &&
|
||||
strings.Contains(textPart.Text, attachmentText) {
|
||||
sawInlinedText = true
|
||||
}
|
||||
}
|
||||
}
|
||||
require.True(t, sawInlinedText, "attachment was not inlined as text")
|
||||
}
|
||||
|
||||
func TestPrepareGenerationSubagentUsesOwnerSyntheticAPIKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -256,7 +366,7 @@ func TestDeriveFinalTurnRunResult(t *testing.T) {
|
||||
modelCfg := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
|
||||
Model: "gpt-4o-mini",
|
||||
DisplayName: "gpt-4o-mini",
|
||||
Options: json.RawMessage(`{}`),
|
||||
Options: json.RawMessage(`{"openai_config":{"use_responses_api":false}}`),
|
||||
}, func(p *database.InsertChatModelConfigParams) {
|
||||
p.Enabled = true
|
||||
p.IsDefault = true
|
||||
@@ -334,6 +444,7 @@ func TestDeriveFinalTurnRunResult(t *testing.T) {
|
||||
require.NotNil(t, result.StatusLabelModel)
|
||||
require.Equal(t, "openai", result.FallbackProvider)
|
||||
require.Equal(t, "gpt-4o-mini", result.FallbackModel)
|
||||
require.JSONEq(t, `{"openai_config":{"use_responses_api":false}}`, string(result.StatusLabelOptions))
|
||||
})
|
||||
|
||||
t.Run("NonWaitingReturnsEmpty", func(t *testing.T) {
|
||||
|
||||
@@ -46,6 +46,7 @@ func newLanguageModel(
|
||||
userAgent string,
|
||||
extraHeaders map[string]string,
|
||||
httpClient *http.Client,
|
||||
openAIResponsesOverride *bool,
|
||||
) (fantasy.LanguageModel, error) {
|
||||
model, err := chatprovider.ModelFromConfig(
|
||||
providerHint,
|
||||
@@ -54,6 +55,7 @@ func newLanguageModel(
|
||||
userAgent,
|
||||
extraHeaders,
|
||||
httpClient,
|
||||
openAIResponsesOverride,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -167,10 +167,11 @@ func (p *Server) newModel(
|
||||
}
|
||||
|
||||
config := fantasyConfigForAIBridge(route.Provider.Type)
|
||||
extraHeaders, err := mergeConfigBetaHeaders(req.ExtraHeaders, config.ProviderHint, req.ConfigOptions)
|
||||
callConfig, err := parseModelConfigOptions(req.ConfigOptions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
extraHeaders := mergeConfigBetaHeaders(req.ExtraHeaders, config.ProviderHint, callConfig)
|
||||
return newLanguageModel(
|
||||
config.ProviderHint,
|
||||
req.ModelName,
|
||||
@@ -178,26 +179,31 @@ func (p *Server) newModel(
|
||||
req.UserAgent,
|
||||
extraHeaders,
|
||||
&http.Client{Transport: baseRT},
|
||||
chatprovider.OpenAIResponsesAPIOverride(callConfig.OpenAIConfig),
|
||||
)
|
||||
}
|
||||
|
||||
func parseModelConfigOptions(configOptions json.RawMessage) (codersdk.ChatModelCallConfig, error) {
|
||||
var callConfig codersdk.ChatModelCallConfig
|
||||
if len(configOptions) == 0 {
|
||||
return callConfig, nil
|
||||
}
|
||||
if err := json.Unmarshal(configOptions, &callConfig); err != nil {
|
||||
return codersdk.ChatModelCallConfig{}, xerrors.Errorf("parse model config options: %w", err)
|
||||
}
|
||||
return callConfig, nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
callConfig codersdk.ChatModelCallConfig,
|
||||
) map[string]string {
|
||||
betaHeaders := chatprovider.BetaHeadersFromCallConfig(providerHint, &callConfig)
|
||||
if len(betaHeaders) == 0 {
|
||||
return extraHeaders, nil
|
||||
return extraHeaders
|
||||
}
|
||||
merged := make(map[string]string, len(extraHeaders)+len(betaHeaders))
|
||||
for name, value := range betaHeaders {
|
||||
@@ -206,7 +212,7 @@ func mergeConfigBetaHeaders(
|
||||
for name, value := range extraHeaders {
|
||||
merged[name] = value
|
||||
}
|
||||
return merged, nil
|
||||
return merged
|
||||
}
|
||||
|
||||
type aibridgeFantasyConfig struct {
|
||||
|
||||
@@ -365,6 +365,78 @@ func TestAIGatewayModelForwardsProviderAuth(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestAIGatewayModelAppliesResponsesAPIOverride(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
newServer := func(t *testing.T, paths chan string) *Server {
|
||||
t.Helper()
|
||||
factory := &aibridgeTestFactory{rt: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
paths <- req.URL.Path
|
||||
body := `{"id":"resp_test","object":"response","created_at":0,"status":"completed","model":"gpt-4","output":[{"id":"msg_test","type":"message","role":"assistant","content":[{"type":"output_text","text":"hello"}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`
|
||||
if strings.HasSuffix(req.URL.Path, "/chat/completions") {
|
||||
body = `{"id":"chatcmpl_test","object":"chat.completion","created":0,"model":"gpt-4","choices":[{"index":0,"message":{"role":"assistant","content":"hello"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})}
|
||||
return &Server{aibridgeTransportFactory: aibridgeTestFactoryPointer(factory)}
|
||||
}
|
||||
|
||||
configOptions := func(t *testing.T, useResponsesAPI *bool) json.RawMessage {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(codersdk.ChatModelCallConfig{
|
||||
OpenAIConfig: &codersdk.ChatModelOpenAIConfig{UseResponsesAPI: useResponsesAPI},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return raw
|
||||
}
|
||||
|
||||
forceResponses := true
|
||||
forceCompletions := false
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
model string
|
||||
override *bool
|
||||
wantPath string
|
||||
}{
|
||||
{name: "ForceResponsesOnUnknownModel", model: "gpt-9-brand-new", override: &forceResponses, wantPath: "/v1/responses"},
|
||||
{name: "ForceCompletionsOnKnownModel", model: "gpt-4o", override: &forceCompletions, wantPath: "/v1/chat/completions"},
|
||||
{name: "UnsetKeepsKnownModelList", model: "gpt-4o", override: nil, wantPath: "/v1/responses"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
paths := make(chan string, 1)
|
||||
server := newServer(t, paths)
|
||||
provider := aibridgeTestAIProvider(uuid.New(), "primary-openai", database.AIProviderTypeOpenai)
|
||||
req := aibridgeTestRequest(database.Chat{ID: uuid.New(), OwnerID: uuid.New()}, tt.model)
|
||||
req.ConfigOptions = configOptions(t, tt.override)
|
||||
|
||||
model, err := server.newModel(
|
||||
t.Context(),
|
||||
req,
|
||||
aibridgeTestRoute(provider),
|
||||
modelBuildOptions{ActiveAPIKeyID: uuid.NewString()},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
_, err = model.Generate(t.Context(), fantasy.Call{Prompt: []fantasy.Message{{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{fantasy.TextPart{Text: "hello"}},
|
||||
}}})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, tt.wantPath, <-paths)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIBridgeRoutingFailClosed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -143,6 +143,7 @@ type shortTextCandidate struct {
|
||||
route aiGatewayModelRoute
|
||||
lm fantasy.LanguageModel
|
||||
providerOptions fantasy.ProviderOptions
|
||||
configOptions json.RawMessage
|
||||
}
|
||||
|
||||
func selectPreferredConfiguredShortTextModelConfig(
|
||||
@@ -320,6 +321,7 @@ func (p *Server) maybeGenerateChatTitle(
|
||||
route: overrideRoute,
|
||||
lm: overrideModel,
|
||||
providerOptions: p.titleGenerationProviderOptions(ctx, overrideModel, overrideConfig),
|
||||
configOptions: overrideConfig.Options,
|
||||
}
|
||||
} else {
|
||||
candidate = shortTextCandidate{
|
||||
@@ -328,6 +330,7 @@ func (p *Server) maybeGenerateChatTitle(
|
||||
route: fallbackRoute,
|
||||
lm: fallbackModel,
|
||||
providerOptions: p.titleGenerationProviderOptions(ctx, fallbackModel, fallbackConfig),
|
||||
configOptions: fallbackConfig.Options,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,11 +425,17 @@ func (p *Server) titleGenerationProviderOptions(
|
||||
)
|
||||
}
|
||||
}
|
||||
providerOptions := chatprovider.ProviderOptionsFromChatModelConfig(model, callConfig.ProviderOptions)
|
||||
responsesOverride := chatprovider.OpenAIResponsesAPIOverride(callConfig.OpenAIConfig)
|
||||
providerOptions := chatprovider.ProviderOptionsFromChatModelConfig(
|
||||
model,
|
||||
callConfig.ProviderOptions,
|
||||
responsesOverride,
|
||||
)
|
||||
return chatprovider.ApplyReasoningEffort(
|
||||
model,
|
||||
providerOptions,
|
||||
chatprovider.ResolveReasoningEffort(nil, callConfig.ReasoningEffort),
|
||||
responsesOverride,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -438,14 +447,16 @@ func (p *Server) newQuickgenDebugModel(
|
||||
model string,
|
||||
route aiGatewayModelRoute,
|
||||
modelOpts modelBuildOptions,
|
||||
configOptions json.RawMessage,
|
||||
) (fantasy.LanguageModel, error) {
|
||||
debugOpts := modelOpts
|
||||
debugOpts.RecordHTTP = true
|
||||
debugModel, err := p.newModel(ctx, modelClientRequest{
|
||||
Chat: chat,
|
||||
ModelName: model,
|
||||
UserAgent: chatprovider.UserAgent(),
|
||||
ExtraHeaders: chatprovider.CoderHeaders(chat),
|
||||
Chat: chat,
|
||||
ModelName: model,
|
||||
UserAgent: chatprovider.UserAgent(),
|
||||
ExtraHeaders: chatprovider.CoderHeaders(chat),
|
||||
ConfigOptions: configOptions,
|
||||
}, route, debugOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -484,6 +495,7 @@ func (p *Server) prepareQuickgenDebugCandidate(
|
||||
candidate.model,
|
||||
candidate.route,
|
||||
modelOpts,
|
||||
candidate.configOptions,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Warn(ctx, "failed to build short-text debug model",
|
||||
@@ -1332,6 +1344,7 @@ func (p *Server) generateTurnStatusLabel(
|
||||
fallbackModel fantasy.LanguageModel,
|
||||
fallbackRoute aiGatewayModelRoute,
|
||||
modelOpts modelBuildOptions,
|
||||
configOptions json.RawMessage,
|
||||
logger slog.Logger,
|
||||
debugSvc *chatdebug.Service,
|
||||
triggerMessageID int64,
|
||||
@@ -1348,10 +1361,11 @@ func (p *Server) generateTurnStatusLabel(
|
||||
"\n\nAgent's latest message:\n" + assistantText
|
||||
|
||||
candidate := shortTextCandidate{
|
||||
provider: fallbackProvider,
|
||||
model: fallbackModelName,
|
||||
route: fallbackRoute,
|
||||
lm: fallbackModel,
|
||||
provider: fallbackProvider,
|
||||
model: fallbackModelName,
|
||||
route: fallbackRoute,
|
||||
lm: fallbackModel,
|
||||
configOptions: configOptions,
|
||||
}
|
||||
|
||||
statusSeedSummary := chatdebug.SeedSummary("Turn status label")
|
||||
|
||||
@@ -1010,6 +1010,7 @@ func openAICompatTestModel(t *testing.T, baseURL string) fantasy.LanguageModel {
|
||||
chatprovider.UserAgent(),
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
return model
|
||||
|
||||
Reference in New Issue
Block a user