Skip to content
Open
18 changes: 18 additions & 0 deletions docs/guides/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,13 +269,31 @@ chat_endpoint_path: /chat/completions
discovery_endpoint_path: /models
generate_max_retries: 5
generate_idle_timeout_sec: 300
prompt_cache: auto
```

新增的生成链路控制字段含义如下:

- `generate_max_retries`:额外重试次数,不含首次尝试;未填写时默认使用 `5`,显式填写 `0` 表示关闭生成重试,且必须 `<= 20`。
- `generate_start_timeout_sec`:写在 `config.yaml` 顶层,从发请求到收到首个有效流 payload 的最长等待窗口;`<= 0` 时回退默认值 `90`。
- `generate_idle_timeout_sec`:首包后连续没有任何新 payload 的最长空闲窗口;`<= 0` 时回退默认值 `300`。
- `prompt_cache`:是否发送 prompt 缓存标记,只接受 `auto` 与 `off`。留空时按 driver 取默认值:`anthropic` 默认 `auto`,其余 driver 默认 `off`。关闭后请求内容不变,但不再携带缓存断点与缓存路由键,用于规避缓存写入溢价或排障。

### prompt 缓存

NeoCode 把系统提示固定切分为"稳定前缀"与"易变尾部"两段,稳定前缀由角色定义与工作区规则文件(如 `AGENTS.md`)组成。各 adapter 按厂商能力映射:

| Driver | 映射方式 |
|--------|----------|
| `anthropic` | 系统提示拆为两个 text block,在稳定块末尾与最后一个工具上放置 `cache_control: ephemeral`;仅当稳定前缀估算达到 1024 token 时才打标 |
| `openaicompat` | `chat completions` 与 `responses` 两种模式均发送 `prompt_cache_key`(由模型、稳定前缀与工具名集合哈希生成),提升同前缀请求的路由命中率 |
| `gemini` | 依赖隐式缓存,不发送额外参数 |
| `deepseek` / `qwen` / `glm` / `mimo` / `minimax` | 不发送路由键:各自有独立的上下文缓存机制,且对未知字段校验更严格 |

默认值按 driver 区分。`cache_control` 是 Anthropic 的官方协议字段,端点不会拒绝,因此默认开启。`openaicompat` 服务任意兼容网关,`prompt_cache_key` 属于可选扩展参数,在未确认端点支持前发送可能让原本可用的网关返回 400,因此默认关闭;需要在该 provider 的 `provider.yaml` 中显式写 `prompt_cache: auto` 声明该端点支持。该设置支持写入 provider 文件并随配置读写往返保留。

memo 索引、todo、git 状态等随会话变化的内容位于易变尾部,不会进入缓存前缀。usage 统计中的 `cache_read_input_tokens` 与 `prompt_tokens_details.cached_tokens` 会归一化到统一字段,缓存命中部分计入总输入预算,不会导致 compact 判定低估。


启动时会自动把缺失的 `generate_start_timeout_sec` 规范化写回 `config.yaml`,避免磁盘配置与运行时默认值不一致。

Expand Down
4 changes: 4 additions & 0 deletions docs/runtime-provider-event-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ provider 返回后,runtime 会执行显式的账本调和。
- `output_tokens`
- `output_source`
- `has_unknown_usage`
- `cache_read_input_tokens`(可选,prompt 缓存命中读取的输入 token)
- `cache_write_input_tokens`(可选,写入 prompt 缓存的输入 token,仅 Anthropic 显式缓存有该口径)

`input_tokens` 按“含缓存的总输入”统计(等于未命中输入 + 缓存命中 + 缓存写入),因此开启 prompt 缓存后预算累计与 compact 判定不会低估。

规则:

Expand Down
48 changes: 48 additions & 0 deletions internal/config/prompt_cache_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package config

import (
"strings"
"testing"

"neo-code/internal/provider"
)

func TestValidateOptionalPromptCache(t *testing.T) {
t.Parallel()

valid := []string{"", "auto", "off", " AUTO ", "Off"}
for _, value := range valid {
if err := validateOptionalPromptCache(value); err != nil {
t.Fatalf("validateOptionalPromptCache(%q) unexpected error: %v", value, err)
}
}

if err := validateOptionalPromptCache("always"); err == nil {
t.Fatal("expected error for unsupported prompt_cache value")
} else if !strings.Contains(err.Error(), provider.PromptCacheModeAuto) {
t.Fatalf("error should mention accepted values, got %v", err)
}
}

func TestProviderConfigPromptCacheReachesRuntimeConfig(t *testing.T) {
t.Parallel()

resolved := ResolvedProviderConfig{
ProviderConfig: ProviderConfig{
Name: "cached-provider",
Driver: "openaicompat",
BaseURL: "https://llm.example.com/v1",
Model: "server-default",
APIKeyEnv: "CACHED_PROVIDER_KEY",
PromptCache: "off",
},
}

runtimeCfg, err := resolved.ToRuntimeConfig()
if err != nil {
t.Fatalf("ToRuntimeConfig() error = %v", err)
}
if runtimeCfg.PromptCacheMode != provider.PromptCacheModeOff {
t.Fatalf("PromptCacheMode = %q, want %q", runtimeCfg.PromptCacheMode, provider.PromptCacheModeOff)
}
}
52 changes: 35 additions & 17 deletions internal/config/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,22 @@ const (
)

type ProviderConfig struct {
Name string `yaml:"name"`
Driver string `yaml:"driver"`
BaseURL string `yaml:"base_url"`
Model string `yaml:"model"`
APIKeyEnv string `yaml:"api_key_env"`
GenerateMaxRetries int `yaml:"generate_max_retries,omitempty"`
GenerateMaxRetriesSet bool `yaml:"-"`
GenerateIdleTimeoutSec int `yaml:"generate_idle_timeout_sec,omitempty"`
ModelSource string `yaml:"-"`
ChatAPIMode string `yaml:"-"`
ChatEndpointPath string `yaml:"-"`
DiscoveryEndpointPath string `yaml:"-"`
Models []providertypes.ModelDescriptor `yaml:"-"`
Source ProviderSource `yaml:"-"`
Name string `yaml:"name"`
Driver string `yaml:"driver"`
BaseURL string `yaml:"base_url"`
Model string `yaml:"model"`
APIKeyEnv string `yaml:"api_key_env"`
GenerateMaxRetries int `yaml:"generate_max_retries,omitempty"`
GenerateMaxRetriesSet bool `yaml:"-"`
GenerateIdleTimeoutSec int `yaml:"generate_idle_timeout_sec,omitempty"`
// PromptCache 控制是否发送 prompt 缓存标记,取值为 auto 或 off,留空视为 auto。
PromptCache string `yaml:"prompt_cache,omitempty"`
Comment thread
Cai-Tang-www marked this conversation as resolved.
Resolved
ModelSource string `yaml:"-"`
ChatAPIMode string `yaml:"-"`
ChatEndpointPath string `yaml:"-"`
DiscoveryEndpointPath string `yaml:"-"`
Models []providertypes.ModelDescriptor `yaml:"-"`
Source ProviderSource `yaml:"-"`
}

type ResolvedProviderConfig struct {
Expand Down Expand Up @@ -94,6 +96,9 @@ func (p ProviderConfig) Validate() error {
if err := validateOptionalGenerateMaxRetries(p.GenerateMaxRetries); err != nil {
return fmt.Errorf("provider %q: %w", p.Name, err)
}
if err := validateOptionalPromptCache(p.PromptCache); err != nil {
return fmt.Errorf("provider %q: %w", p.Name, err)
}
if err := validateOptionalGenerateDurationSeconds("generate_idle_timeout_sec", p.GenerateIdleTimeoutSec); err != nil {
return fmt.Errorf("provider %q: %w", p.Name, err)
}
Expand Down Expand Up @@ -134,6 +139,17 @@ func validateOptionalNonNegativeGenerateControl(field string, value int) error {
return nil
}

// validateOptionalPromptCache 校验 prompt 缓存开关,只接受 auto 与 off,空值表示使用默认值。
func validateOptionalPromptCache(value string) error {
normalized := strings.ToLower(strings.TrimSpace(value))
switch normalized {
case "", provider.PromptCacheModeAuto, provider.PromptCacheModeOff:
return nil
default:
return fmt.Errorf("prompt_cache must be %q or %q", provider.PromptCacheModeAuto, provider.PromptCacheModeOff)
}
}

// validateOptionalGenerateMaxRetries 校验额外重试次数,防止超大值导致生成阶段重试循环过长。
func validateOptionalGenerateMaxRetries(value int) error {
if err := validateOptionalNonNegativeGenerateControl("generate_max_retries", value); err != nil {
Expand Down Expand Up @@ -309,9 +325,11 @@ func (p ResolvedProviderConfig) ToRuntimeConfig() (provider.RuntimeConfig, error
ChatAPIMode: chatAPIMode,
ChatEndpointPath: chatEndpointPath,
DiscoveryEndpointPath: discoveryEndpointPath,
GenerateMaxRetries: p.resolveGenerateMaxRetries(),
GenerateStartTimeout: provider.NormalizeGenerateStartTimeout(time.Duration(p.GenerateStartTimeoutSec) * time.Second),
GenerateIdleTimeout: provider.NormalizeGenerateIdleTimeout(time.Duration(p.GenerateIdleTimeoutSec) * time.Second),
// 保留显式取值(含空值),由 provider 层按 driver 决定默认模式。
PromptCacheMode: strings.ToLower(strings.TrimSpace(p.PromptCache)),
GenerateMaxRetries: p.resolveGenerateMaxRetries(),
GenerateStartTimeout: provider.NormalizeGenerateStartTimeout(time.Duration(p.GenerateStartTimeoutSec) * time.Second),
GenerateIdleTimeout: provider.NormalizeGenerateIdleTimeout(time.Duration(p.GenerateIdleTimeoutSec) * time.Second),
}, nil
}

Expand Down
2 changes: 2 additions & 0 deletions internal/config/provider_custom_normalize.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ func NormalizeCustomProviderInput(input SaveCustomProviderInput) (SaveCustomProv
GenerateMaxRetriesSet: input.GenerateMaxRetriesSet || input.GenerateMaxRetries > 0,
GenerateIdleTimeoutSec: normalizeOptionalGenerateInt(input.GenerateIdleTimeoutSec),
DiscoveryEndpointPath: strings.TrimSpace(input.DiscoveryEndpointPath),
PromptCache: strings.ToLower(strings.TrimSpace(input.PromptCache)),
}

if err := validateCustomProviderName(normalized.Name); err != nil {
Expand Down Expand Up @@ -121,6 +122,7 @@ func validateNormalizedCustomProviderInput(input SaveCustomProviderInput) error
ChatAPIMode: input.ChatAPIMode,
ChatEndpointPath: input.ChatEndpointPath,
DiscoveryEndpointPath: input.DiscoveryEndpointPath,
PromptCache: input.PromptCache,
Models: input.Models,
Source: ProviderSourceCustom,
}
Expand Down
28 changes: 17 additions & 11 deletions internal/config/provider_loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,19 @@ const (
)

type customProviderFile struct {
Name string `yaml:"name"`
Driver string `yaml:"driver"`
APIKeyEnv string `yaml:"api_key_env"`
GenerateMaxRetries *int `yaml:"generate_max_retries,omitempty"`
GenerateIdleTimeoutSec int `yaml:"generate_idle_timeout_sec,omitempty"`
ModelSource string `yaml:"model_source,omitempty"`
ChatAPIMode string `yaml:"chat_api_mode,omitempty"`
BaseURL string `yaml:"base_url,omitempty"`
ChatEndpointPath string `yaml:"chat_endpoint_path,omitempty"`
DiscoveryEndpointPath string `yaml:"discovery_endpoint_path,omitempty"`
Models []customProviderModelFile `yaml:"models,omitempty"`
Name string `yaml:"name"`
Driver string `yaml:"driver"`
APIKeyEnv string `yaml:"api_key_env"`
GenerateMaxRetries *int `yaml:"generate_max_retries,omitempty"`
GenerateIdleTimeoutSec int `yaml:"generate_idle_timeout_sec,omitempty"`
ModelSource string `yaml:"model_source,omitempty"`
ChatAPIMode string `yaml:"chat_api_mode,omitempty"`
BaseURL string `yaml:"base_url,omitempty"`
ChatEndpointPath string `yaml:"chat_endpoint_path,omitempty"`
DiscoveryEndpointPath string `yaml:"discovery_endpoint_path,omitempty"`
// PromptCache 控制是否发送 prompt 缓存标记,取值 auto 或 off。
PromptCache string `yaml:"prompt_cache,omitempty"`
Models []customProviderModelFile `yaml:"models,omitempty"`
}

type customProviderModelFile struct {
Expand Down Expand Up @@ -120,6 +122,7 @@ func loadCustomProvider(providerDir string) (ProviderConfig, error) {
ChatAPIMode: strings.TrimSpace(file.ChatAPIMode),
ChatEndpointPath: strings.TrimSpace(file.ChatEndpointPath),
DiscoveryEndpointPath: strings.TrimSpace(file.DiscoveryEndpointPath),
PromptCache: strings.TrimSpace(file.PromptCache),
Models: models,
})
if err != nil {
Expand All @@ -138,6 +141,7 @@ func loadCustomProvider(providerDir string) (ProviderConfig, error) {
ChatAPIMode: normalizedInput.ChatAPIMode,
ChatEndpointPath: normalizedInput.ChatEndpointPath,
DiscoveryEndpointPath: normalizedInput.DiscoveryEndpointPath,
PromptCache: normalizedInput.PromptCache,
Models: normalizedInput.Models,
Source: ProviderSourceCustom,
}
Expand Down Expand Up @@ -194,6 +198,7 @@ type SaveCustomProviderInput struct {
GenerateIdleTimeoutSec int
DiscoveryEndpointPath string
ModelSource string
PromptCache string
Models []providertypes.ModelDescriptor
}

Expand All @@ -217,6 +222,7 @@ func SaveCustomProviderWithModels(baseDir string, input SaveCustomProviderInput)
GenerateIdleTimeoutSec: normalizedInput.GenerateIdleTimeoutSec,
ModelSource: normalizedInput.ModelSource,
ChatAPIMode: normalizedInput.ChatAPIMode,
PromptCache: normalizedInput.PromptCache,
}

cfg.BaseURL = normalizedInput.BaseURL
Expand Down
119 changes: 119 additions & 0 deletions internal/config/provider_prompt_cache_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package config

import (
"context"
"os"
"path/filepath"
"strings"
"testing"

"neo-code/internal/provider"
)

func TestCustomProviderPromptCacheRoundTrip(t *testing.T) {
t.Parallel()

baseDir := t.TempDir()
const providerName = "cached-gateway"
if err := SaveCustomProviderWithModels(baseDir, SaveCustomProviderInput{
Name: providerName,
Driver: provider.DriverOpenAICompat,
BaseURL: "https://llm.example.com/v1",
APIKeyEnv: "CACHED_GATEWAY_KEY",
ModelSource: ModelSourceDiscover,
DiscoveryEndpointPath: provider.DiscoveryEndpointPathModels,
PromptCache: "OFF",
}); err != nil {
t.Fatalf("SaveCustomProviderWithModels() error = %v", err)
}

cfg, err := loadCustomProvider(filepath.Join(baseDir, providersDirName, providerName))
if err != nil {
t.Fatalf("loadCustomProvider() error = %v", err)
}
if cfg.PromptCache != provider.PromptCacheModeOff {
t.Fatalf("PromptCache = %q, want %q after round trip", cfg.PromptCache, provider.PromptCacheModeOff)
}

runtimeCfg, err := cfg.Resolve()
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
providerCfg, err := runtimeCfg.ToRuntimeConfig()
if err != nil {
t.Fatalf("ToRuntimeConfig() error = %v", err)
}
if providerCfg.PromptCacheMode != provider.PromptCacheModeOff {
t.Fatalf("PromptCacheMode = %q, want %q", providerCfg.PromptCacheMode, provider.PromptCacheModeOff)
}
}

func TestCustomProviderFileAcceptsPromptCacheKey(t *testing.T) {
t.Parallel()

loader := NewLoader(t.TempDir(), testDefaultConfig())
customDir := filepath.Join(loader.BaseDir(), providersDirName, "company-gateway")
if err := os.MkdirAll(customDir, 0o755); err != nil {
t.Fatalf("mkdir custom provider dir: %v", err)
}

// 该键曾被 KnownFields(true) 拒绝:结构体缺少 prompt_cache 字段时,按文档写配置会直接加载失败。
providerYAML := `
name: company-gateway
driver: openaicompat
base_url: https://llm.example.com/v1
api_key_env: COMPANY_GATEWAY_API_KEY
discovery_endpoint_path: /models
prompt_cache: off
`
if err := os.WriteFile(filepath.Join(customDir, customProviderConfigName), []byte(strings.TrimSpace(providerYAML)+"\n"), 0o644); err != nil {
t.Fatalf("write provider.yaml: %v", err)
}

cfg, err := loader.Load(context.Background())
if err != nil {
t.Fatalf("Load() error = %v", err)
}
found := false
for _, p := range cfg.Providers {
if p.Name == "company-gateway" {
found = true
if p.PromptCache != provider.PromptCacheModeOff {
t.Fatalf("PromptCache = %q, want off", p.PromptCache)
}
}
}
if !found {
t.Fatal("custom provider company-gateway not loaded")
}
}

func TestCustomProviderFileRejectsInvalidPromptCache(t *testing.T) {
t.Parallel()

loader := NewLoader(t.TempDir(), testDefaultConfig())
customDir := filepath.Join(loader.BaseDir(), providersDirName, "bad-cache-gateway")
if err := os.MkdirAll(customDir, 0o755); err != nil {
t.Fatalf("mkdir custom provider dir: %v", err)
}

providerYAML := `
name: bad-cache-gateway
driver: openaicompat
base_url: https://llm.example.com/v1
api_key_env: BAD_CACHE_GATEWAY_KEY
discovery_endpoint_path: /models
prompt_cache: always
`
if err := os.WriteFile(filepath.Join(customDir, customProviderConfigName), []byte(strings.TrimSpace(providerYAML)+"\n"), 0o644); err != nil {
t.Fatalf("write provider.yaml: %v", err)
}

_, err := loader.Load(context.Background())
if err == nil {
t.Fatal("expected load to reject unsupported prompt_cache value")
}
if !strings.Contains(err.Error(), "prompt_cache") {
t.Fatalf("error should mention prompt_cache, got %v", err)
}
}
Loading
Loading