Skip to content

Commit 0a8b7cd

Browse files
committed
v0.5.0 — Model profiles + context window management
Model Profile System: - Per-model defaults for thinking mode, timeout, and context window - Longest-prefix matching — add new models with one entry in KnownProfiles - Human-readable profile labels in CLI output - deepseek-v4-pro: thinking=enabled, 180s timeout, 1M context - deepseek-v4-flash: no thinking default, 90s timeout, 128K context Context Window Management: - Token estimation heuristic (zero-dep, ~4 chars/token) - Automatic message trimming before LLM calls at 75% of MaxContext - Preserves system prompt and original task, drops oldest tool pairs - Proportional timeouts per model profile Testing: 95 tests passing (+47 new: profile lookup, context trim, E2E CLI)
1 parent 2b58a11 commit 0a8b7cd

9 files changed

Lines changed: 837 additions & 67 deletions

File tree

README.md

Lines changed: 108 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,10 @@ kode run --model gpt-4o "Write a Go test for the loop engine"
8282

8383
| Flag | Type | Default | Description |
8484
|------|------|---------|-------------|
85-
| `--model <name>` | string | `deepseek-chat` | LLM model identifier |
85+
| `--model <name>` | string | `deepseek-chat` | LLM model identifier. Known profiles auto-set thinking/timeout — see [Model Profiles](#model-profiles). |
8686
| `--base-url <url>` | string | `https://api.deepseek.com/v1` | OpenAI-compatible API endpoint |
8787
| `--max-iter <n>` | int | `90` | Maximum think→act cycles before giving up |
88-
| `--thinking <level>` | string | (none) | Reasoning depth — see [Thinking Levels](#thinking-levels) |
88+
| `--thinking <level>` | string | profile default | Reasoning depth — see [Thinking Levels](#thinking-levels). Leave empty for profile/provider default. |
8989
| `--sandbox` | bool | false | Run all shell commands inside an isolated Docker container |
9090
| `--system <prompt>` | string | built-in | Override the system prompt |
9191

@@ -151,9 +151,101 @@ Any endpoint that accepts `POST /chat/completions` with an OpenAI-compatible JSO
151151

152152
---
153153

154+
## Model Profiles
155+
156+
kode ships with built-in **model profiles** that automatically apply sensible defaults (thinking mode, request timeout) based on the model name. Profiles are matched by longest prefix — adding a new model is one entry.
157+
158+
| Model | Family | Default Thinking | Timeout | Max Context | Best For |
159+
|-------|--------|-----------------|---------|-------------|----------|
160+
| `deepseek-chat` | DeepSeek (legacy) | (provider default) | 120s | 128K | General purpose |
161+
| `deepseek-v4-flash` | DeepSeek v4 Flash | — (faster/cheaper) | 90s | 128K | Quick tasks, coding |
162+
| `deepseek-v4-pro` | DeepSeek v4 Pro | `enabled` | 180s | **1M** | Deep reasoning, analysis |
163+
| *(any other)* | Generic | (profile default) | 120s | (no limit) | Custom models |
164+
165+
### How profiles work
166+
167+
1. When you set `--model deepseek-v4-pro`, kode matches the profile and **automatically sets `thinking=enabled`** and a **180s timeout**.
168+
2. Explicit `--thinking` always wins — profile defaults only apply when you don't specify.
169+
3. Unknown models get no profile overrides (provider default behavior).
170+
171+
### Adding a new profile
172+
173+
Profiles live in `kode.go` as the `KnownProfiles` slice. Adding a new model is a single entry:
174+
175+
```go
176+
{
177+
Prefix: "claude-sonnet-4",
178+
Profile: ModelProfile{
179+
Label: "Claude Sonnet 4",
180+
DefaultThinking: "", // no extended thinking
181+
Timeout: 180, // generous for reasoning
182+
MaxContext: 200_000, // 200K context window
183+
},
184+
},
185+
```
186+
187+
No changes to the LLM client, loop engine, or CLI parsing needed — the rest of kode consumes `KnownProfiles` automatically.
188+
189+
```bash
190+
# DeepSeek v4 Pro — thinking enabled automatically, 180s timeout, 1M context
191+
kode run --model deepseek-v4-pro "Design a distributed consensus algorithm"
192+
193+
# DeepSeek v4 Flash — no extended thinking, 90s timeout, 128K context
194+
kode run --model deepseek-v4-flash "List the files"
195+
196+
# Override profile default explicitly
197+
kode run --model deepseek-v4-pro --thinking disabled "Quick status check"
198+
```
199+
200+
---
201+
202+
## Context Window Management
203+
204+
kode automatically manages the conversation history to stay within each model's context window. When a model profile defines a `MaxContext` value, the loop engine estimates token usage and trims old messages before they fill the window.
205+
206+
### How it works
207+
208+
1. **Token estimation**: kode uses a conservative heuristic (~4 chars/token + structural overhead) — no tokenizer needed.
209+
2. **Safety margin**: 75% of `MaxContext` is reserved for input; the remaining 25% is left for the model's output.
210+
3. **Trim strategy**: Before each LLM call, if estimated tokens exceed the budget, the oldest non-essential messages (tool call → tool result pairs) are dropped, preserving:
211+
- The **system prompt** (always first)
212+
- The **original task message** (first user message)
213+
4. **No limit = no trimming**: Models with `MaxContext: 0` (or no profile) have no context enforcement — the full history is sent every time.
214+
215+
### Example
216+
217+
```
218+
Messages before trim (6 messages, ~250K estimated tokens, budget=200K):
219+
[system] You are kode...
220+
[user] Refactor this module...
221+
[assistant]" ← DROPPED
222+
[tool] ← DROPPED
223+
[assistant] Let me check... ← KEPT
224+
[tool] File: main.go... ← KEPT
225+
226+
Messages after trim (4 messages, ~180K estimated tokens):
227+
[system] You are kode...
228+
[user] Refactor this module...
229+
[assistant] Let me check...
230+
[tool] File: main.go...
231+
```
232+
233+
### Token estimation accuracy
234+
235+
| Content | Estimated | Actual (approx) | Notes |
236+
|---------|-----------|-----------------|-------|
237+
| "hello" | 2 tokens | ~1 token | Overestimates short text |
238+
| 1000 chars of text | 250 tokens | ~200-300 tokens | Accurate range |
239+
| Code/JSON | Variable | 2-3 chars/token | Conservative overestimate |
240+
| Message JSON overhead | 50 per msg | ~30-50 | Margin for nested fields |
241+
242+
The estimator is intentionally conservative — it overestimates to prevent context limit errors. In practice, the model never sees a `context_length_exceeded` error because trimming happens proactively at 75% of the limit.
243+
244+
---
245+
154246
## Thinking Levels
155247

156-
The `--thinking` flag controls how deeply the model reasons before responding. kode automatically maps your value to the provider's native format.
248+
The `--thinking` flag controls how deeply the model reasons before responding. kode automatically maps your value to the provider's native format. When the flag is not set, the [model profile](#model-profiles) default is applied (if any).
157249

158250
| Value | Deepseek sends | OpenAI o-series sends | Description |
159251
|-------|---------------|----------------------|-------------|
@@ -165,15 +257,18 @@ The `--thinking` flag controls how deeply the model reasons before responding. k
165257
| (empty) | (not sent) | (not sent) | Provider default behavior |
166258

167259
```bash
168-
# Deepseek — enable extended thinking
169-
kode run --model deepseek-chat --thinking enabled "Explain monads"
260+
# DeepSeek v4 Pro — profile auto-enables thinking
261+
kode run --model deepseek-v4-pro "Explain monads"
170262

171-
# Deepseek — disable (faster, cheaper)
172-
kode run --model deepseek-chat --thinking disabled "List files"
263+
# DeepSeek v4 Flash — profile default: no thinking (faster, cheaper)
264+
kode run --model deepseek-v4-flash "List files"
173265

174266
# OpenAI o1 — deep reasoning for hard problems
175267
kode run --model o1 --base-url https://api.openai.com/v1 --thinking high "Optimize this distributed consensus algorithm"
176268

269+
# Override profile default with explicit flag
270+
kode run --model deepseek-v4-pro --thinking disabled "Quick status check"
271+
177272
# Default (no thinking field sent) — let the provider decide
178273
kode run "What time is it?"
179274
```
@@ -351,10 +446,10 @@ func main() {
351446

352447
| Field | Type | Default | Description |
353448
|-------|------|---------|-------------|
354-
| `Model` | string | `"deepseek-chat"` | LLM model ID |
449+
| `Model` | string | `"deepseek-chat"` | LLM model ID — triggers [model profile](#model-profiles) defaults |
355450
| `BaseURL` | string | `"https://api.deepseek.com/v1"` | API endpoint |
356451
| `APIKey` | string | `$DEEPSEEK_API_KEY` or `$OPENAI_API_KEY` | Auth token |
357-
| `Thinking` | string | `""` | Reasoning depth — see [Thinking Levels](#thinking-levels) |
452+
| `Thinking` | string | profile default (if any) | Reasoning depth — see [Thinking Levels](#thinking-levels) |
358453
| `Tools` | `[]Tool` | `nil` | Available tools |
359454
| `MaxIterations` | int | `90` | Max think→act cycles |
360455
| `SystemMessage` | string | built-in | System prompt |
@@ -380,7 +475,8 @@ The API key can also be set programmatically via `Config.APIKey` — explicit co
380475
| Model | `deepseek-chat` |
381476
| Base URL | `https://api.deepseek.com/v1` |
382477
| Max iterations | `90` |
383-
| Thinking | (not sent — provider default) |
478+
| Thinking | Profile default (if known model), else provider default) |
479+
| HTTP timeout | Profile default (120s for unknown models) |
384480

385481
---
386482

@@ -472,8 +568,8 @@ Requires Go 1.24+. Zero external test dependencies — tests use `httptest`, `te
472568

473569
| Package | Tests | Focus |
474570
|---------|-------|-------|
475-
| `kode` | 11 | Config defaults, API key fallback, thinking passthrough, system message |
476-
| `internal/llm` | 11 | JSON marshaling, thinking/reasoning_effort fields, response parsing |
571+
| `kode` | 25 | Config defaults, API key fallback, thinking passthrough, system message, model profiles, lookup, label, timeout |
572+
| `internal/llm` | 14 | JSON marshaling, thinking/reasoning_effort fields, response parsing, custom timeout |
477573
| `internal/loop` | 7 | ReAct engine with httptest mock (simple answer, tool calls, max iter, cancellation) |
478574
| `internal/tool` | 7 | Registry CRUD, Get (found/not found), duplicate detection |
479575

cmd/kode/main.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,13 @@ func printUsage() {
108108
109109
Flags:
110110
--model <name> LLM model (default: deepseek-chat)
111+
Known profiles: deepseek-v4-flash, deepseek-v4-pro
112+
Profiles auto-set thinking/timeout defaults.
111113
--base-url <url> API endpoint (default: https://api.deepseek.com/v1)
112114
--max-iter <n> Max think->act cycles (default: 90)
113-
--thinking <level> Reasoning depth: enabled|disabled (Deepseek) or low|medium|high (OpenAI o-series)
115+
--thinking <level> Reasoning depth: enabled|disabled (Deepseek)
116+
or low|medium|high (OpenAI o-series).
117+
Empty = profile default = provider default.
114118
--sandbox Run in isolated Docker container
115119
--no-color Disable colored terminal output
116120
--system <prompt> System prompt override`)
@@ -141,12 +145,12 @@ func run(args []string) error {
141145
}
142146

143147
// Create terminal renderer for colored step-by-step output.
144-
modelName := f.Model
145-
if modelName == "" {
146-
modelName = "deepseek-chat"
148+
modelLabel := kode.ProfileLabel(f.Model)
149+
if modelLabel == "" {
150+
modelLabel = "deepseek-chat"
147151
}
148152
color := !f.NoColor && render.ColorEnabled()
149-
rend := render.New(os.Stderr, color)
153+
rend := render.New(os.Stderr, color).WithModel(modelLabel)
150154

151155
agent, err := kode.New(kode.Config{
152156
Model: f.Model,
@@ -156,7 +160,7 @@ func run(args []string) error {
156160
Thinking: f.Thinking,
157161
Tools: tools,
158162
SandboxCleanup: sandboxCleanup,
159-
Renderer: rend.WithModel(modelName),
163+
Renderer: rend,
160164
})
161165
if err != nil {
162166
return err

cmd/kode/main_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,9 +192,13 @@ func TestPrintUsage(t *testing.T) {
192192
"kode run",
193193
"kode version",
194194
"--model",
195+
"Known profiles",
196+
"deepseek-v4-flash",
197+
"deepseek-v4-pro",
195198
"--base-url",
196199
"--max-iter",
197200
"--thinking",
201+
"profile default",
198202
"--sandbox",
199203
"--system",
200204
}

internal/llm/client.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,19 @@ type Client struct {
2121
http *http.Client
2222
}
2323

24-
// New creates a Client with sensible defaults.
25-
func New(baseURL, apiKey, model, thinking string) *Client {
24+
// New creates a Client with the given timeout. Pass 0 to use the default
25+
// (120s). The timeout applies per HTTP request — the agent loop may have
26+
// multiple requests; set a generous timeout for deep-reasoning models.
27+
func New(baseURL, apiKey, model, thinking string, timeout time.Duration) *Client {
28+
if timeout <= 0 {
29+
timeout = 120 * time.Second
30+
}
2631
return &Client{
2732
BaseURL: strings.TrimRight(baseURL, "/"),
2833
APIKey: apiKey,
2934
Model: model,
3035
Thinking: thinking,
31-
http: &http.Client{Timeout: 120 * time.Second},
36+
http: &http.Client{Timeout: timeout},
3237
}
3338
}
3439

internal/llm/client_test.go

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"net/http"
77
"net/http/httptest"
88
"testing"
9+
"time"
910
)
1011

1112
func TestCallParamsMarshaling_NoThinking(t *testing.T) {
@@ -330,7 +331,7 @@ func TestClient_ThinkingSwitch(t *testing.T) {
330331
}
331332

332333
func TestClient_New(t *testing.T) {
333-
c := New("https://api.example.com/v1", "sk-key", "gpt-4", "enabled")
334+
c := New("https://api.example.com/v1", "sk-key", "gpt-4", "enabled", 0)
334335
if c.BaseURL != "https://api.example.com/v1" {
335336
t.Errorf("BaseURL = %q", c.BaseURL)
336337
}
@@ -346,12 +347,26 @@ func TestClient_New(t *testing.T) {
346347
}
347348

348349
func TestClient_New_TrailingSlash(t *testing.T) {
349-
c := New("https://api.example.com/v1/", "sk-key", "model", "")
350+
c := New("https://api.example.com/v1/", "sk-key", "model", "", 0)
350351
if c.BaseURL != "https://api.example.com/v1" {
351352
t.Errorf("BaseURL should trim trailing slash, got %q", c.BaseURL)
352353
}
353354
}
354355

356+
func TestClient_New_CustomTimeout(t *testing.T) {
357+
c := New("https://api.example.com", "sk-key", "model", "", 30*time.Second)
358+
if c.http.Timeout != 30*time.Second {
359+
t.Errorf("Timeout = %v, want 30s", c.http.Timeout)
360+
}
361+
}
362+
363+
func TestClient_New_ZeroTimeoutUsesDefault(t *testing.T) {
364+
c := New("https://api.example.com", "sk-key", "model", "", 0)
365+
if c.http.Timeout != 120*time.Second {
366+
t.Errorf("Timeout = %v, want 120s", c.http.Timeout)
367+
}
368+
}
369+
355370
func TestClient_Call_Success(t *testing.T) {
356371
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
357372
// Verify request method and path
@@ -366,7 +381,7 @@ func TestClient_Call_Success(t *testing.T) {
366381
}))
367382
defer server.Close()
368383

369-
c := New(server.URL, "sk-test", "test-model", "")
384+
c := New(server.URL, "sk-test", "test-model", "", 0)
370385
result, err := c.Call(context.Background(), []Message{{Role: "user", Content: "hi"}}, nil)
371386
if err != nil {
372387
t.Fatalf("Call() error: %v", err)
@@ -383,7 +398,7 @@ func TestClient_Call_HTTPError(t *testing.T) {
383398
}))
384399
defer server.Close()
385400

386-
c := New(server.URL, "sk-test", "test-model", "")
401+
c := New(server.URL, "sk-test", "test-model", "", 0)
387402
_, err := c.Call(context.Background(), []Message{{Role: "user", Content: "hi"}}, nil)
388403
if err == nil {
389404
t.Fatal("expected error for 500 response")
@@ -399,7 +414,7 @@ func TestClient_Call_WithThinking(t *testing.T) {
399414
}))
400415
defer server.Close()
401416

402-
c := New(server.URL, "sk-test", "deepseek-chat", "enabled")
417+
c := New(server.URL, "sk-test", "deepseek-chat", "enabled", 0)
403418
result, err := c.Call(context.Background(), []Message{{Role: "user", Content: "think"}}, nil)
404419
if err != nil {
405420
t.Fatalf("Call() error: %v", err)
@@ -427,7 +442,7 @@ func TestClient_Call_WithReasoningEffort(t *testing.T) {
427442
}))
428443
defer server.Close()
429444

430-
c := New(server.URL, "sk-test", "o1", "high")
445+
c := New(server.URL, "sk-test", "o1", "high", 0)
431446
result, err := c.Call(context.Background(), []Message{{Role: "user", Content: "reason"}}, nil)
432447
if err != nil {
433448
t.Fatalf("Call() error: %v", err)
@@ -442,7 +457,7 @@ func TestClient_Call_WithReasoningEffort(t *testing.T) {
442457
}
443458

444459
func TestClient_Call_InvalidEndpoint(t *testing.T) {
445-
c := New("http://127.0.0.1:1", "sk-test", "model", "")
460+
c := New("http://127.0.0.1:1", "sk-test", "model", "", 0)
446461
_, err := c.Call(context.Background(), []Message{{Role: "user", Content: "hi"}}, nil)
447462
if err == nil {
448463
t.Fatal("expected connection error")
@@ -457,7 +472,7 @@ func TestClient_Call_WithTools(t *testing.T) {
457472
}))
458473
defer server.Close()
459474

460-
c := New(server.URL, "sk-test", "test-model", "")
475+
c := New(server.URL, "sk-test", "test-model", "", 0)
461476
tools := []ToolDef{
462477
{
463478
Type: "function",
@@ -485,7 +500,7 @@ func TestClient_Call_Unauthorized(t *testing.T) {
485500
}))
486501
defer server.Close()
487502

488-
c := New(server.URL, "sk-bad", "test-model", "")
503+
c := New(server.URL, "sk-bad", "test-model", "", 0)
489504
_, err := c.Call(context.Background(), []Message{{Role: "user", Content: "hi"}}, nil)
490505
if err == nil {
491506
t.Fatal("expected error for 401 response")
@@ -500,7 +515,7 @@ func TestClient_Call_InvalidJSONResponse(t *testing.T) {
500515
}))
501516
defer server.Close()
502517

503-
c := New(server.URL, "sk-test", "test-model", "")
518+
c := New(server.URL, "sk-test", "test-model", "", 0)
504519
_, err := c.Call(context.Background(), []Message{{Role: "user", Content: "hi"}}, nil)
505520
if err == nil {
506521
t.Fatal("expected error for invalid JSON response")

0 commit comments

Comments
 (0)