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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions config/config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
7 changes: 7 additions & 0 deletions config/virtualmodels.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Expand Down
2 changes: 1 addition & 1 deletion internal/virtualmodels/batch_preparer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
5 changes: 3 additions & 2 deletions internal/virtualmodels/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
Expand Down
143 changes: 143 additions & 0 deletions internal/virtualmodels/disable_reasoning_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
58 changes: 43 additions & 15 deletions internal/virtualmodels/redirect_rewrite.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"fmt"
"strings"

"github.com/goccy/go-json"

"github.com/enterpilot/gomodel/internal/core"
)

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
18 changes: 18 additions & 0 deletions internal/virtualmodels/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions internal/virtualmodels/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions internal/virtualmodels/store_mongodb.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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(),
Expand Down
Loading