diff --git a/config/config.example.yaml b/config/config.example.yaml index 0fe428ab..c037aefc 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -72,6 +72,12 @@ models: # targets: # - { model: openai/gpt-4o } # - { model: groq/llama-3.3-70b } +# # Kimi Code subscription: route the canonical K2.6 behavior through the +# # kimi-for-coding endpoint with thinking disabled. Set disable_reasoning on +# # any redirect whose target's non-thinking variant is what you want. +# - source: kimi-k2.6 +# target: kimicode/kimi-for-coding +# disable_reasoning: true # MCP gateway: aggregate upstream MCP (Model Context Protocol) servers behind the # authenticated /mcp endpoint. Tools/prompts are namespaced as {server}_{name}; diff --git a/config/virtualmodels.go b/config/virtualmodels.go index a191d537..31fc2641 100644 --- a/config/virtualmodels.go +++ b/config/virtualmodels.go @@ -45,6 +45,13 @@ type VirtualModelConfig struct { // nil leaves the setting unspecified. Slowdown *float64 `yaml:"slowdown,omitempty" json:"slowdown,omitempty"` + // DisableReasoning strips reasoning controls (the typed reasoning field and + // reasoning_effort) from requests through this redirect and forces the + // provider's thinking toggle off, so the request is served by the + // non-thinking model variant (e.g. Kimi Code's K2.6 behind + // kimi-for-coding). Only meaningful for redirects. + DisableReasoning bool `yaml:"disable_reasoning,omitempty" json:"disable_reasoning,omitempty"` + // Enabled toggles the entry. It defaults to true when omitted. Enabled *bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` } diff --git a/internal/virtualmodels/batch_preparer.go b/internal/virtualmodels/batch_preparer.go index f482c979..f7df4c32 100644 --- a/internal/virtualmodels/batch_preparer.go +++ b/internal/virtualmodels/batch_preparer.go @@ -91,7 +91,7 @@ func rewriteBatchItem( } // resolveRedirectRoutableSelector is user-path aware (scoped redirects), so a // caller outside a scoped alias's user_paths gets the literal name here too. - resolved, err := resolveRedirectRoutableSelector(ctx, service, checker, requested, providerType) + resolved, _, err := resolveRedirectRoutableSelector(ctx, service, checker, requested, providerType) if err != nil { return nil, err } diff --git a/internal/virtualmodels/config.go b/internal/virtualmodels/config.go index 53e4e35d..9e8c355c 100644 --- a/internal/virtualmodels/config.go +++ b/internal/virtualmodels/config.go @@ -32,8 +32,9 @@ func configModel(entry config.VirtualModelConfig) VirtualModel { Targets: configTargets(entry), UserPaths: entry.UserPaths, Description: entry.Description, - Slowdown: entry.Slowdown, - Enabled: enabled, + Slowdown: entry.Slowdown, + DisableReasoning: entry.DisableReasoning, + Enabled: enabled, Managed: true, } } diff --git a/internal/virtualmodels/disable_reasoning_test.go b/internal/virtualmodels/disable_reasoning_test.go new file mode 100644 index 00000000..fcbb3b2f --- /dev/null +++ b/internal/virtualmodels/disable_reasoning_test.go @@ -0,0 +1,143 @@ +package virtualmodels + +import ( + "context" + "testing" + + "github.com/goccy/go-json" + + "github.com/enterpilot/gomodel/config" + "github.com/enterpilot/gomodel/internal/core" +) + +func TestRewriteChatRequest_DisableReasoningStripsControls(t *testing.T) { + t.Parallel() + ctx := context.Background() + svc := newTestService(t) + if err := svc.Upsert(ctx, VirtualModel{ + Source: "kimi-k2.6", + Targets: []Target{{Provider: "openai", Model: "gpt-4o"}}, + DisableReasoning: true, + Enabled: true, + }); err != nil { + t.Fatalf("Upsert(redirect) error = %v", err) + } + checker := testCatalog() + + req := &core.ChatRequest{ + Model: "kimi-k2.6", + Reasoning: &core.Reasoning{Effort: "high"}, + ExtraFields: core.UnknownJSONFieldsFromMap(map[string]json.RawMessage{ + "reasoning_effort": json.RawMessage(`"high"`), + "keep_me": json.RawMessage(`"yes"`), + }), + } + chat, err := rewriteChatRequest(ctx, svc, checker, req) + if err != nil { + t.Fatalf("rewriteChatRequest() error = %v", err) + } + if chat.Model != "gpt-4o" || chat.Provider != "openai" { + t.Fatalf("rewriteChatRequest() selector = %q/%q, want openai/gpt-4o", chat.Provider, chat.Model) + } + if chat.Reasoning != nil { + t.Fatalf("Reasoning = %+v, want nil when disable_reasoning is set", chat.Reasoning) + } + if got := chat.ExtraFields.Lookup("reasoning_effort"); got != nil { + t.Fatalf("reasoning_effort = %s, want stripped", got) + } + thinking := chat.ExtraFields.Lookup("thinking") + if thinking == nil { + t.Fatal("thinking field missing, want thinking.type=disabled") + } + var thinkingMap map[string]string + if err := json.Unmarshal(thinking, &thinkingMap); err != nil || thinkingMap["type"] != "disabled" { + t.Fatalf("thinking = %s, want {\"type\":\"disabled\"}", thinking) + } + if got := chat.ExtraFields.Lookup("keep_me"); string(got) != `"yes"` { + t.Fatalf("keep_me = %s, want preserved", got) + } + + // The caller's request must not be mutated. + if req.Reasoning == nil || req.Reasoning.Effort != "high" { + t.Fatalf("caller Reasoning mutated: %+v", req.Reasoning) + } + if got := req.ExtraFields.Lookup("reasoning_effort"); string(got) != `"high"` { + t.Fatalf("caller reasoning_effort mutated: %s", got) + } +} + +func TestRewriteChatRequest_DisableReasoningUnsetKeepsControls(t *testing.T) { + t.Parallel() + ctx := context.Background() + svc := newRedirectService(t) + checker := testCatalog() + + req := &core.ChatRequest{ + Model: "fast", + Reasoning: &core.Reasoning{Effort: "high"}, + ExtraFields: core.UnknownJSONFieldsFromMap(map[string]json.RawMessage{ + "reasoning_effort": json.RawMessage(`"high"`), + }), + } + chat, err := rewriteChatRequest(ctx, svc, checker, req) + if err != nil { + t.Fatalf("rewriteChatRequest() error = %v", err) + } + if chat.Reasoning == nil || chat.Reasoning.Effort != "high" { + t.Fatalf("Reasoning = %+v, want preserved when disable_reasoning is unset", chat.Reasoning) + } + if got := chat.ExtraFields.Lookup("reasoning_effort"); string(got) != `"high"` { + t.Fatalf("reasoning_effort = %s, want preserved", got) + } + if got := chat.ExtraFields.Lookup("thinking"); got != nil { + t.Fatalf("thinking = %s, want absent when disable_reasoning is unset", got) + } +} + +func TestService_DisableReasoningForSource(t *testing.T) { + t.Parallel() + svc := newTestService(t) + ctx := context.Background() + if err := svc.Upsert(ctx, VirtualModel{ + Source: "k26", + Targets: []Target{{Provider: "openai", Model: "gpt-4o"}}, + DisableReasoning: true, + Enabled: true, + }); err != nil { + t.Fatalf("Upsert(k26) error = %v", err) + } + if err := svc.Upsert(ctx, VirtualModel{ + Source: "k27", + Targets: []Target{{Provider: "openai", Model: "gpt-4o"}}, + Enabled: true, + }); err != nil { + t.Fatalf("Upsert(k27) error = %v", err) + } + + if !svc.DisableReasoningForSource("k26") { + t.Error("DisableReasoningForSource(k26) = false, want true") + } + if svc.DisableReasoningForSource("k27") { + t.Error("DisableReasoningForSource(k27) = true, want false") + } + if svc.DisableReasoningForSource("missing") { + t.Error("DisableReasoningForSource(missing) = true, want false") + } + if svc.DisableReasoningForSource("") { + t.Error("DisableReasoningForSource(\"\") = true, want false") + } +} + +func TestConfigModel_MapsDisableReasoning(t *testing.T) { + t.Parallel() + models := ConfigModels([]config.VirtualModelConfig{{Source: "k26", Target: "openai/gpt-4o", DisableReasoning: true}}) + if len(models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(models)) + } + if !models[0].DisableReasoning { + t.Error("DisableReasoning = false, want true after config mapping") + } + if !models[0].Managed { + t.Error("Managed = false, want true for config-declared models") + } +} diff --git a/internal/virtualmodels/redirect_rewrite.go b/internal/virtualmodels/redirect_rewrite.go index 157043d1..ccc1bbd3 100644 --- a/internal/virtualmodels/redirect_rewrite.go +++ b/internal/virtualmodels/redirect_rewrite.go @@ -5,6 +5,8 @@ import ( "fmt" "strings" + "github.com/goccy/go-json" + "github.com/enterpilot/gomodel/internal/core" ) @@ -54,38 +56,41 @@ type modelProviderTypeChecker interface { // resolveRedirectRequestSelector resolves a request-time selector through the // redirect table honoring the caller's user path, so a user_paths-scoped redirect // is not applied for callers outside its scope (it falls through to the literal -// name). -func resolveRedirectRequestSelector(ctx context.Context, service *Service, requested core.RequestedModelSelector) (core.ModelSelector, error) { +// name). It also returns the matched redirect's source, or "" when no redirect +// applied, so callers can consult per-redirect flags. +func resolveRedirectRequestSelector(ctx context.Context, service *Service, requested core.RequestedModelSelector) (core.ModelSelector, string, error) { if service == nil { - return requested.Normalize() + selector, err := requested.Normalize() + return selector, "", err } - selector, changed, err := service.ResolveModelForUserPath(ctx, requested) + resolution, changed, err := service.ResolveRedirectForUserPath(ctx, requested) if err != nil { - return core.ModelSelector{}, err + return core.ModelSelector{}, "", err } if changed { - return selector, nil + return resolution.Resolved, resolution.Source, nil } - return requested.Normalize() + selector, err := requested.Normalize() + return selector, "", err } -func resolveRedirectRoutableSelector(ctx context.Context, service *Service, checker modelSupportChecker, requested core.RequestedModelSelector, expectedProviderType string) (core.ModelSelector, error) { - selector, err := resolveRedirectRequestSelector(ctx, service, requested) +func resolveRedirectRoutableSelector(ctx context.Context, service *Service, checker modelSupportChecker, requested core.RequestedModelSelector, expectedProviderType string) (core.ModelSelector, string, error) { + selector, source, err := resolveRedirectRequestSelector(ctx, service, requested) if err != nil { - return core.ModelSelector{}, err + return core.ModelSelector{}, "", err } resolvedModel := strings.TrimSpace(selector.QualifiedModel()) if resolvedModel == "" { - return core.ModelSelector{}, core.NewInvalidRequestError("model is required", nil) + return core.ModelSelector{}, "", core.NewInvalidRequestError("model is required", nil) } if checker == nil || !checker.Supports(resolvedModel) { - return core.ModelSelector{}, core.NewModelNotFoundError(resolvedModel) + return core.ModelSelector{}, "", core.NewModelNotFoundError(resolvedModel) } if err := validateResolvedProviderType(checker, selector, expectedProviderType); err != nil { - return core.ModelSelector{}, err + return core.ModelSelector{}, "", err } - return selector, nil + return selector, source, nil } func validateResolvedProviderType(checker modelSupportChecker, selector core.ModelSelector, expectedProviderType string) error { @@ -120,12 +125,35 @@ func rewriteChatRequest(ctx context.Context, service *Service, checker modelSupp if req == nil { return nil, nil } - selector, err := resolveRedirectRoutableSelector(ctx, service, checker, core.NewRequestedModelSelector(req.Model, req.Provider), "") + selector, source, err := resolveRedirectRoutableSelector(ctx, service, checker, core.NewRequestedModelSelector(req.Model, req.Provider), "") if err != nil { return nil, err } forward := *req forward.Model = selector.Model forward.Provider = selector.Provider + if service.DisableReasoningForSource(source) { + if err := applyDisableReasoning(&forward); err != nil { + return nil, err + } + } return &forward, nil } + +// applyDisableReasoning strips reasoning controls from the forwarded request so +// the upstream serves the non-thinking variant of the model. The typed +// Reasoning field and the flat reasoning_effort extra are dropped; a +// thinking.type=disabled extra is set so providers with a thinking toggle +// (Kimi Code, Anthropic-style) route to the non-thinking model. +func applyDisableReasoning(req *core.ChatRequest) error { + req.Reasoning = nil + extra := req.ExtraFields.Without("reasoning_effort") + merged, err := core.MergeUnknownJSONFields(extra, map[string]json.RawMessage{ + "thinking": json.RawMessage(`{"type":"disabled"}`), + }) + if err != nil { + return core.NewInvalidRequestError("failed to disable reasoning for redirect target: "+err.Error(), err) + } + req.ExtraFields = merged + return nil +} diff --git a/internal/virtualmodels/resolve.go b/internal/virtualmodels/resolve.go index 2f0c5d22..ba33f3f1 100644 --- a/internal/virtualmodels/resolve.go +++ b/internal/virtualmodels/resolve.go @@ -51,6 +51,24 @@ func (s *Service) ResolveModelForUserPath(ctx context.Context, requested core.Re return resolution.Resolved, changed, nil } +// ResolveRedirectForUserPath is ResolveModelForUserPath plus the matched +// redirect's source name, so request rewrites can consult per-redirect flags +// (e.g. DisableReasoning) via Resolution.Source. +func (s *Service) ResolveRedirectForUserPath(ctx context.Context, requested core.RequestedModelSelector) (Resolution, bool, error) { + return s.resolveRequested(requested, core.UserPathFromContext(ctx), true, core.SessionIDFromContext(ctx)) +} + +// DisableReasoningForSource reports whether the redirect with the given source +// has the DisableReasoning flag set, so request rewrites can strip reasoning +// controls before forwarding to the resolved target. +func (s *Service) DisableReasoningForSource(source string) bool { + if s == nil || source == "" { + return false + } + entry, ok := s.snapshot().redirects[source] + return ok && entry.vm.DisableReasoning +} + // ResolveRefreshTarget returns a redirect target without consulting the current // catalog so callers can refresh an unavailable target provider before normal // resolution is retried. diff --git a/internal/virtualmodels/service.go b/internal/virtualmodels/service.go index 361ce41d..ca3af4ee 100644 --- a/internal/virtualmodels/service.go +++ b/internal/virtualmodels/service.go @@ -267,8 +267,9 @@ func (s *Service) ListViews() []View { Model: vm.Model, UserPaths: vm.UserPaths, Description: vm.Description, - Slowdown: vm.Slowdown, - Enabled: vm.Enabled, + Slowdown: vm.Slowdown, + DisableReasoning: vm.DisableReasoning, + Enabled: vm.Enabled, Managed: vm.Managed, CreatedAt: vm.CreatedAt, UpdatedAt: vm.UpdatedAt, diff --git a/internal/virtualmodels/store_mongodb.go b/internal/virtualmodels/store_mongodb.go index ed064c21..16257368 100644 --- a/internal/virtualmodels/store_mongodb.go +++ b/internal/virtualmodels/store_mongodb.go @@ -22,6 +22,7 @@ type mongoVirtualModelDocument struct { UserPaths []string `bson:"user_paths,omitempty"` Description string `bson:"description,omitempty"` Slowdown *float64 `bson:"slowdown,omitempty"` + DisableReasoning bool `bson:"disable_reasoning,omitempty"` Enabled bool `bson:"enabled"` CreatedAt time.Time `bson:"created_at"` UpdatedAt time.Time `bson:"updated_at"` @@ -103,6 +104,7 @@ func (s *MongoDBStore) Upsert(ctx context.Context, vm VirtualModel) error { "user_paths": vm.UserPaths, "description": vm.Description, "slowdown": vm.Slowdown, + "disable_reasoning": vm.DisableReasoning, "enabled": vm.Enabled, "updated_at": vm.UpdatedAt, }, @@ -141,6 +143,7 @@ func virtualModelFromMongo(doc mongoVirtualModelDocument) VirtualModel { Model: doc.Model, Description: doc.Description, Slowdown: doc.Slowdown, + DisableReasoning: doc.DisableReasoning, Enabled: doc.Enabled, CreatedAt: doc.CreatedAt.UTC(), UpdatedAt: doc.UpdatedAt.UTC(), diff --git a/internal/virtualmodels/store_sql.go b/internal/virtualmodels/store_sql.go index 4b53a256..94aca554 100644 --- a/internal/virtualmodels/store_sql.go +++ b/internal/virtualmodels/store_sql.go @@ -26,6 +26,7 @@ var sqlSchema = []string{ user_paths TEXT NOT NULL DEFAULT '[]', description TEXT NOT NULL DEFAULT '', slowdown DOUBLE PRECISION DEFAULT NULL, + disable_reasoning ` + sqlx.TypeBool + ` NOT NULL DEFAULT FALSE, enabled ` + sqlx.TypeBool + ` NOT NULL DEFAULT TRUE, created_at ` + sqlx.TypeInt64 + ` NOT NULL, updated_at ` + sqlx.TypeInt64 + ` NOT NULL @@ -40,19 +41,20 @@ var sqlSchema = []string{ var virtualModelMigrations = []string{ "ALTER TABLE virtual_models ADD COLUMN session_affinity TEXT NOT NULL DEFAULT ''", "ALTER TABLE virtual_models ADD COLUMN slowdown DOUBLE PRECISION DEFAULT NULL", + "ALTER TABLE virtual_models ADD COLUMN disable_reasoning " + sqlx.TypeBool + " NOT NULL DEFAULT FALSE", } const selectVirtualModelColumns = ` SELECT source, targets, strategy, session_affinity, provider_name, model, user_paths, - description, slowdown, enabled, created_at, updated_at + description, slowdown, disable_reasoning, enabled, created_at, updated_at FROM virtual_models ` const upsertVirtualModelSQL = ` INSERT INTO virtual_models ( - source, targets, strategy, session_affinity, provider_name, model, user_paths, description, slowdown, enabled, created_at, updated_at + source, targets, strategy, session_affinity, provider_name, model, user_paths, description, slowdown, disable_reasoning, enabled, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(source) DO UPDATE SET targets = excluded.targets, strategy = excluded.strategy, @@ -62,6 +64,7 @@ const upsertVirtualModelSQL = ` user_paths = excluded.user_paths, description = excluded.description, slowdown = excluded.slowdown, + disable_reasoning = excluded.disable_reasoning, enabled = excluded.enabled, updated_at = excluded.updated_at ` @@ -160,6 +163,7 @@ func virtualModelUpsertArgs(vm VirtualModel) ([]any, error) { pathsJSON, vm.Description, vm.Slowdown, + vm.DisableReasoning, vm.Enabled, vm.CreatedAt.Unix(), vm.UpdatedAt.Unix(), @@ -181,6 +185,7 @@ func scanSQLVirtualModel(scanner sqlx.Row) (VirtualModel, error) { &userPaths, &vm.Description, &vm.Slowdown, + &vm.DisableReasoning, &vm.Enabled, &createdAt, &updatedAt, diff --git a/internal/virtualmodels/types.go b/internal/virtualmodels/types.go index 411c0234..f3a7f9a6 100644 --- a/internal/virtualmodels/types.go +++ b/internal/virtualmodels/types.go @@ -57,6 +57,12 @@ type VirtualModel struct { // enabled (the default); explicit false restores stateless balancing. SessionAffinity *bool `json:"session_affinity,omitempty" bson:"session_affinity,omitempty"` + // DisableReasoning strips reasoning controls from requests through this + // redirect and forces the provider's thinking toggle off, so the request is + // served by the non-thinking model variant (e.g. Kimi Code's K2.6 behind + // kimi-for-coding). Only meaningful for redirects. + DisableReasoning bool `json:"disable_reasoning,omitempty" bson:"disable_reasoning,omitempty"` + CreatedAt time.Time `json:"created_at" bson:"created_at"` UpdatedAt time.Time `json:"updated_at" bson:"updated_at"` @@ -151,8 +157,9 @@ type View struct { UserPaths []string `json:"user_paths,omitempty"` Description string `json:"description,omitempty"` // Slowdown is an extra-time factor from 0.1 to 10; zero disables it. - Slowdown *float64 `json:"slowdown,omitempty"` - Enabled bool `json:"enabled"` + Slowdown *float64 `json:"slowdown,omitempty"` + DisableReasoning bool `json:"disable_reasoning,omitempty"` + Enabled bool `json:"enabled"` Managed bool `json:"managed,omitempty"` ResolvedModel string `json:"resolved_model,omitempty"` ProviderType string `json:"provider_type,omitempty"`