Skip to content
Merged
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
15 changes: 11 additions & 4 deletions cmd/odek/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down Expand Up @@ -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()
Expand Down
16 changes: 8 additions & 8 deletions cmd/odek/serve_api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down
48 changes: 48 additions & 0 deletions cmd/odek/serve_model_shortcuts_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
}
8 changes: 8 additions & 0 deletions docs/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
9 changes: 8 additions & 1 deletion internal/llmclient/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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},
Expand Down
3 changes: 3 additions & 0 deletions internal/llmclient/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
49 changes: 49 additions & 0 deletions internal/llmclient/model_shortcuts_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
})
}
}
Loading