From df8aed5d57c171c4c78b963d2475c9ddcfe21f96 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso <4096860+jkyberneees@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:33:16 +0200 Subject: [PATCH] fix(models): add stable DeepSeek shortcuts and 1M context --- cmd/odek/serve.go | 15 +++++-- cmd/odek/serve_api_test.go | 16 +++---- cmd/odek/serve_model_shortcuts_test.go | 48 +++++++++++++++++++++ docs/CONFIG.md | 8 ++++ internal/llmclient/client.go | 9 +++- internal/llmclient/client_test.go | 3 ++ internal/llmclient/model_shortcuts_test.go | 49 ++++++++++++++++++++++ 7 files changed, 135 insertions(+), 13 deletions(-) create mode 100644 cmd/odek/serve_model_shortcuts_test.go create mode 100644 internal/llmclient/model_shortcuts_test.go diff --git a/cmd/odek/serve.go b/cmd/odek/serve.go index 033b0ea..9a6e44d 100644 --- a/cmd/odek/serve.go +++ b/cmd/odek/serve.go @@ -629,7 +629,7 @@ func newServeMux(d serveMuxDeps) *http.ServeMux { mux.Handle("/api/resources", apiAuth(handleResourceSearch(resourceReg))) mux.Handle("/api/sessions", apiAuth(handleSessionListPaged(store))) mux.Handle("/api/sessions/", apiAuth(handleSessionByID(store, resolved.TrustedProxies, wsToken))) - mux.Handle("/api/models", apiAuth(handleModelList(resolved.Model, newServeModelLister(resolved)))) + mux.Handle("/api/models", apiAuth(handleModelList(resolved.Model, newServeModelLister(resolved), resolved.Provider))) mux.Handle("/api/limits", apiAuth(handleLimits(resolved.Model, resolved.Limits))) mux.Handle("/api/cancel", apiAuth(handleCancel(store))) mux.Handle("/api/health", apiAuth(handleHealth(state))) @@ -3187,22 +3187,29 @@ func modelListEntry(id, display string, maxCtx int, current bool) modelEntry { display = id } e := modelEntry{ID: id, MaxContext: maxCtx, Description: display, Current: current} - if maxCtx > 0 { + if maxCtx > 0 && maxCtx%1_000_000 == 0 { + e.Description = fmt.Sprintf("%s — %dM ctx", display, maxCtx/1_000_000) + } else if maxCtx > 0 { e.Description = fmt.Sprintf("%s — %dK ctx", display, maxCtx/1024) } return e } // handleModelList is GET /api/models. The payload is the provider's -// ListModels catalog (when the lister is set) plus the configured model, +// ListModels catalog (when the lister is set), provider shortcuts, and the configured model, // marked current. /api/profiles is retired — this is the picker source. -func handleModelList(configuredModel string, list modelLister) http.HandlerFunc { +func handleModelList(configuredModel string, list modelLister, provider string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } byID := make(map[string]modelEntry) + if provider == "deepseek" || provider == "" { + for _, id := range []string{"deepseek-flash", "deepseek-pro"} { + byID[id] = modelListEntry(id, id, llmclient.LastResortContext(id), id == configuredModel) + } + } if list != nil { ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() diff --git a/cmd/odek/serve_api_test.go b/cmd/odek/serve_api_test.go index ea7a252..0f99f1f 100644 --- a/cmd/odek/serve_api_test.go +++ b/cmd/odek/serve_api_test.go @@ -432,7 +432,7 @@ func TestHandleSessionByID_GET_RateLimit(t *testing.T) { func TestHandleModelList_ReturnsOnlyConfiguredModel(t *testing.T) { // Must return exactly one entry — the configured model — not KnownProfiles. - handler := handleModelList("deepseek-v4-flash", nil) + handler := handleModelList("deepseek-v4-flash", nil, "custom") req := httptest.NewRequest(http.MethodGet, "/api/models", nil) w := httptest.NewRecorder() handler(w, req) @@ -463,7 +463,7 @@ func TestHandleModelList_ReturnsOnlyConfiguredModel(t *testing.T) { } func TestHandleModelList_DeepSeekV41UsesV4Window(t *testing.T) { - handler := handleModelList("deepseek-v4.1-flash-expires-on-0910", nil) + handler := handleModelList("deepseek-v4.1-flash-expires-on-0910", nil, "custom") w := httptest.NewRecorder() handler(w, httptest.NewRequest(http.MethodGet, "/api/models", nil)) var models []modelEntry @@ -479,7 +479,7 @@ func TestHandleModelList_DeepSeekV41UsesV4Window(t *testing.T) { } func TestHandleModelList_EmptyConfigModel_ReturnsEmptyList(t *testing.T) { - handler := handleModelList("", nil) + handler := handleModelList("", nil, "custom") req := httptest.NewRequest(http.MethodGet, "/api/models", nil) w := httptest.NewRecorder() handler(w, req) @@ -498,7 +498,7 @@ func TestHandleModelList_EmptyConfigModel_ReturnsEmptyList(t *testing.T) { func TestHandleModelList_UnknownModelStillReturned(t *testing.T) { // A custom model not in KnownProfiles must still appear in the list. - handler := handleModelList("my-custom-llm", nil) + handler := handleModelList("my-custom-llm", nil, "custom") req := httptest.NewRequest(http.MethodGet, "/api/models", nil) w := httptest.NewRecorder() handler(w, req) @@ -528,7 +528,7 @@ func TestHandleModelList_MergesListedModels(t *testing.T) { {ID: "glm-5.3-flash", DisplayName: "GLM 5.3 Flash", ContextWindow: 0}, }, nil } - handler := handleModelList("glm-5.3-flash", list) + handler := handleModelList("glm-5.3-flash", list, "custom") w := httptest.NewRecorder() handler(w, httptest.NewRequest(http.MethodGet, "/api/models", nil)) var models []modelEntry @@ -558,7 +558,7 @@ func TestHandleModelList_OpenAILastResortContext(t *testing.T) { {ID: "gpt-4o", DisplayName: "GPT-4o", ContextWindow: 0}, }, nil } - handler := handleModelList("gpt-5.6-luna", list) + handler := handleModelList("gpt-5.6-luna", list, "custom") w := httptest.NewRecorder() handler(w, httptest.NewRequest(http.MethodGet, "/api/models", nil)) var models []modelEntry @@ -735,7 +735,7 @@ func TestHandleLimits_MethodNotAllowed(t *testing.T) { } func TestHandleModelList_MethodNotAllowed(t *testing.T) { - handler := handleModelList("m", nil) + handler := handleModelList("m", nil, "custom") for _, method := range []string{http.MethodPost, http.MethodDelete, http.MethodPut} { req := httptest.NewRequest(method, "/api/models", nil) w := httptest.NewRecorder() @@ -749,7 +749,7 @@ func TestHandleModelList_MethodNotAllowed(t *testing.T) { func TestHandleModelList_NoDeepSeekHardcoding(t *testing.T) { // Verify that the list does NOT contain KnownProfiles entries when a // non-deepseek model is configured. The old bug included all KnownProfiles. - handler := handleModelList("gpt-4o", nil) + handler := handleModelList("gpt-4o", nil, "custom") req := httptest.NewRequest(http.MethodGet, "/api/models", nil) w := httptest.NewRecorder() handler(w, req) diff --git a/cmd/odek/serve_model_shortcuts_test.go b/cmd/odek/serve_model_shortcuts_test.go new file mode 100644 index 0000000..d02baf8 --- /dev/null +++ b/cmd/odek/serve_model_shortcuts_test.go @@ -0,0 +1,48 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +func TestHandleModelList_DeepSeekShortcuts(t *testing.T) { + for _, provider := range []string{"deepseek", "", "custom"} { + for _, online := range []bool{false, true} { + list := func(context.Context) ([]listedModel, error) { + if !online { + return nil, errors.New("offline") + } + return []listedModel{{ID: "deepseek-flash"}, {ID: "deepseek-v4-pro"}}, nil + } + w := httptest.NewRecorder() + handleModelList("deepseek-flash", list, provider)(w, httptest.NewRequest(http.MethodGet, "/api/models", nil)) + var models []modelEntry + if err := json.Unmarshal(w.Body.Bytes(), &models); err != nil { + t.Fatal(err) + } + if len(models) == 0 || models[0].ID != "deepseek-flash" || !models[0].Current { + t.Fatalf("current model must remain first: %+v", models) + } + seen := map[string]bool{} + for _, model := range models { + if seen[model.ID] { + t.Fatalf("duplicate model: %q", model.ID) + } + seen[model.ID] = true + if model.MaxContext != 1_000_000 { + t.Errorf("%s context = %d", model.ID, model.MaxContext) + } + } + if seen["deepseek-pro"] != (provider != "custom") { + t.Errorf("provider %q shortcuts: %+v", provider, models) + } + if online && !seen["deepseek-v4-pro"] { + t.Error("legacy model removed from provider catalog") + } + } + } +} diff --git a/docs/CONFIG.md b/docs/CONFIG.md index bd6c6e9..19022d2 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -315,6 +315,14 @@ Top-level execution knobs. Every one also exists as a CLI flag and an `ODEK_*` e | `no_agents` | `false` | Skip loading project `AGENTS.md` | | `system` | built-in | Override the system-prompt identity layer — name/mission/persona (operator-only; rejected from project configs). The invariant security pillar is always composed on top and cannot be overridden. | +With `provider: "deepseek"`, the model picker includes `deepseek-flash` and +`deepseek-pro`, each with a 1,000,000-token context fallback. These IDs also work +with `--model` and runtime model switching. Flash is sent directly; the Pro +shortcut sends `deepseek-v4-pro` to DeepSeek. Existing versioned IDs remain valid. +Custom providers receive model IDs unchanged. See +[DeepSeek's model documentation](https://api-docs.deepseek.com/quick_start/pricing/) +for current provider routing and availability. + ## LLM client (`llm`) Tunes the shared LLM client (streaming and buffered calls share one retry policy): diff --git a/internal/llmclient/client.go b/internal/llmclient/client.go index aff36ec..b3d06a8 100644 --- a/internal/llmclient/client.go +++ b/internal/llmclient/client.go @@ -174,6 +174,11 @@ func New(s *sdk.SDK, providerID, model string) (*Client, error) { if err != nil { return nil, err } + // Pro is an Odek shortcut until DeepSeek publishes a versionless Pro ID. + // Keep custom providers' model namespaces untouched. + if providerID == "deepseek" && model == "deepseek-pro" { + model = "deepseek-v4-pro" + } chat, err := s.Chat(providerID, model) if err != nil { return nil, err @@ -648,7 +653,9 @@ func LastResortContext(model string) int { {"k3-256k", 262_144}, {"k3", 1_000_000}, {"deepseek-v4", 1_000_000}, // pro, flash, v4.1-* (official 1M window) - {"deepseek-", 131_072}, // v3 chat / reasoner + {"deepseek-flash", 1_000_000}, + {"deepseek-pro", 1_000_000}, + {"deepseek-", 131_072}, // v3 chat / reasoner // OpenAI — api.openai.com ListModels omits context_length. {"gpt-6", 1_050_000}, {"gpt-5.6", 1_050_000}, diff --git a/internal/llmclient/client_test.go b/internal/llmclient/client_test.go index 0626390..6ff64e7 100644 --- a/internal/llmclient/client_test.go +++ b/internal/llmclient/client_test.go @@ -78,6 +78,9 @@ func TestLastResortContext(t *testing.T) { model string want int }{ + {"deepseek-flash", 1_000_000}, + {"deepseek-pro", 1_000_000}, + {" DEEPSEEK-FLASH ", 1_000_000}, {"deepseek-v4-flash", 1_000_000}, {"deepseek-v4-pro", 1_000_000}, {"deepseek-v4.1", 1_000_000}, diff --git a/internal/llmclient/model_shortcuts_test.go b/internal/llmclient/model_shortcuts_test.go new file mode 100644 index 0000000..9abbabd --- /dev/null +++ b/internal/llmclient/model_shortcuts_test.go @@ -0,0 +1,49 @@ +package llmclient + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestDeepSeekShortcutRequests(t *testing.T) { + for _, provider := range []string{"deepseek", "legacy"} { + t.Run(provider, func(t *testing.T) { + requests := make(chan string, 3) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body struct { + Model string `json:"model"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + requests <- body.Model + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`)) + })) + defer server.Close() + client, err := Dial(provider, "deepseek-flash", "test-key", server.URL) + if err != nil { + t.Fatal(err) + } + for _, model := range []string{"deepseek-flash", "deepseek-pro", "deepseek-v4-pro"} { + client, err = client.RebindModel(model) + if err != nil { + t.Fatal(err) + } + if _, err := client.SimpleCall(context.Background(), "test", "hello"); err != nil { + t.Fatal(err) + } + want := model + if provider == "deepseek" && model == "deepseek-pro" { + want = "deepseek-v4-pro" + } + if got := <-requests; got != want { + t.Fatalf("%s request model = %q, want %q", model, got, want) + } + } + }) + } +}