返回 DeepSeek-Reasonix
runtime_http.go
根目录 / internal / serve / runtime_http.go
1 package serve
2
3 import (
4 "encoding/json"
5 "fmt"
6 "net/http"
7 "strings"
8
9 "reasonix/internal/config"
10 "reasonix/internal/control"
11 )
12
13 func (s *Server) modelSwitch(w http.ResponseWriter, r *http.Request) {
14 var body struct {
15 Ref string `json:"ref"`
16 }
17 if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Ref) == "" {
18 http.Error(w, "missing model ref", http.StatusBadRequest)
19 return
20 }
21 ref, err := s.canonicalRuntimeModelRef(body.Ref)
22 if err != nil {
23 http.Error(w, err.Error(), http.StatusBadRequest)
24 return
25 }
26 if err := s.switchModelExpected(r.Context(), ref, r.Header.Get(expectedSessionPathHeader)); err != nil {
27 http.Error(w, err.Error(), runtimeSwitchErrorStatus(err))
28 return
29 }
30 w.WriteHeader(http.StatusNoContent)
31 }
32
33 // submitModelCommand handles the text-command twin of POST /model.
34 func (s *Server) submitModelCommand(w http.ResponseWriter, r *http.Request, input string) bool {
35 if !strings.HasPrefix(input, "/model ") {
36 return false
37 }
38 ref, err := s.canonicalRuntimeModelRef(strings.TrimSpace(strings.TrimPrefix(input, "/model")))
39 if err != nil {
40 http.Error(w, err.Error(), http.StatusBadRequest)
41 return true
42 }
43 if err := s.switchModelExpected(r.Context(), ref, r.Header.Get(expectedSessionPathHeader)); err != nil {
44 http.Error(w, err.Error(), runtimeSwitchErrorStatus(err))
45 return true
46 }
47 w.WriteHeader(http.StatusNoContent)
48 return true
49 }
50
51 // canonicalRuntimeModelRef converts an HTTP-provided selector into a value
52 // owned by the active provider catalog or on-disk configuration. Besides
53 // rejecting models the UI could not have listed, returning the trusted catalog
54 // value prevents request data from becoming a provider/session path input.
55 func (s *Server) canonicalRuntimeModelRef(raw string) (string, error) {
56 requested := strings.TrimSpace(raw)
57 if requested == "" {
58 return "", fmt.Errorf("missing model ref")
59 }
60 for _, descriptor := range s.ctl().ProviderCatalog() {
61 candidate := strings.TrimSpace(descriptor.Ref)
62 if candidate != "" && candidate == requested {
63 return candidate, nil
64 }
65 }
66 cfg, err := config.Load()
67 if err != nil {
68 return "", fmt.Errorf("load config: %w", err)
69 }
70 entry, ok := cfg.ResolveModel(requested)
71 if !ok {
72 return "", fmt.Errorf("unknown model ref %q", requested)
73 }
74 for i := range cfg.Providers {
75 providerEntry := &cfg.Providers[i]
76 if providerEntry.Name != entry.Name {
77 continue
78 }
79 models := providerEntry.ChatModelList()
80 if len(models) == 0 {
81 models = providerEntry.ModelList()
82 }
83 for _, model := range models {
84 if model == entry.Model {
85 return providerEntry.Name + "/" + model, nil
86 }
87 }
88 break
89 }
90 return "", fmt.Errorf("unknown model ref %q", requested)
91 }
92
93 func (s *Server) effortSwitch(w http.ResponseWriter, r *http.Request) {
94 var body struct {
95 Level string `json:"level"`
96 }
97 if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Level) == "" {
98 http.Error(w, "missing effort level", http.StatusBadRequest)
99 return
100 }
101 if err := s.switchEffortExpected(r.Context(), strings.TrimSpace(body.Level), r.Header.Get(expectedSessionPathHeader)); err != nil {
102 http.Error(w, err.Error(), runtimeSwitchErrorStatus(err))
103 return
104 }
105 w.WriteHeader(http.StatusNoContent)
106 }
107
108 // qualityFloorSwitch retains the retired route for older clients. It validates
109 // the value and session identity but never changes runtime state.
110 func (s *Server) qualityFloorSwitch(w http.ResponseWriter, r *http.Request) {
111 var body struct {
112 Floor string `json:"floor"`
113 }
114 if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Floor) == "" {
115 http.Error(w, "missing quality floor", http.StatusBadRequest)
116 return
117 }
118 normalized, err := control.NormalizeQualityFloor(body.Floor)
119 if err != nil {
120 http.Error(w, err.Error(), http.StatusBadRequest)
121 return
122 }
123 s.bindMu.Lock()
124 defer s.bindMu.Unlock()
125 if !s.validateExpectedSessionLocked(w, r) {
126 return
127 }
128 if err := s.ctl().SetQualityFloor(normalized); err != nil {
129 http.Error(w, err.Error(), http.StatusBadRequest)
130 return
131 }
132 w.WriteHeader(http.StatusNoContent)
133 }
134
135 func (s *Server) goalPause(w http.ResponseWriter, _ *http.Request) {
136 if !s.ctl().PauseGoal() {
137 http.Error(w, "the active goal cannot be paused", http.StatusConflict)
138 return
139 }
140 w.WriteHeader(http.StatusNoContent)
141 }
142
143 func (s *Server) goalResume(w http.ResponseWriter, _ *http.Request) {
144 if !s.ctl().ResumeGoal() {
145 http.Error(w, "the active goal cannot be resumed", http.StatusConflict)
146 return
147 }
148 w.WriteHeader(http.StatusNoContent)
149 }
150
151 func (s *Server) jobsCancel(w http.ResponseWriter, r *http.Request) {
152 var body struct {
153 IDs []string `json:"ids"`
154 }
155 if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
156 http.Error(w, "invalid job cancellation request", http.StatusBadRequest)
157 return
158 }
159 canceller, ok := any(s.ctl()).(interface{ CancelJob(string) bool })
160 if !ok {
161 http.Error(w, "background job cancellation is unavailable", http.StatusConflict)
162 return
163 }
164 cancelled := []string{}
165 notRunning := []string{}
166 seen := map[string]bool{}
167 for _, raw := range body.IDs {
168 id := strings.TrimSpace(raw)
169 if id == "" || seen[id] {
170 continue
171 }
172 seen[id] = true
173 if canceller.CancelJob(id) {
174 cancelled = append(cancelled, id)
175 } else {
176 notRunning = append(notRunning, id)
177 }
178 }
179 if len(seen) == 0 {
180 http.Error(w, "at least one job id is required", http.StatusBadRequest)
181 return
182 }
183 writeJSON(w, map[string]any{"cancelled": cancelled, "notRunning": notRunning})
184 }
185
186 func runtimeSwitchErrorStatus(err error) int {
187 message := err.Error()
188 if strings.Contains(message, "active work") || strings.Contains(message, "session in use") || strings.Contains(message, "session changed") {
189 return http.StatusConflict
190 }
191 return http.StatusInternalServerError
192 }
193
194 // providersReload rebuilds the current model after an on-disk provider or
195 // credential-tunnel endpoint change. Busy serves return a retryable 409.
196 func (s *Server) providersReload(w http.ResponseWriter, r *http.Request) {
197 s.bindMu.Lock()
198 defer s.bindMu.Unlock()
199 ref := s.ctl().ModelRef()
200 err := s.switchModelLocked(r.Context(), ref)
201 if err != nil {
202 // A credential heal can update the managed provider's default model
203 // while the running controller still carries the old explicit ref
204 // ("proxy/deepseek-v4-flash" after the block moved to v4-pro); that
205 // ref no longer resolves and would fail every reload forever. Fall
206 // back to the provider-only ref — the same form the serve launches
207 // with — so the rebuild adopts the provider's current default.
208 if provider, _, ok := strings.Cut(ref, "/"); ok {
209 if perr := s.switchModelLocked(r.Context(), provider); perr == nil {
210 err = nil
211 ref = provider
212 }
213 }
214 }
215 if err != nil {
216 http.Error(w, err.Error(), http.StatusConflict)
217 return
218 }
219 s.retireDetachedForProviderHeal()
220 writeJSON(w, map[string]string{"model": ref})
221 }
222
222 lines GO