Skip to content
Open
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
18 changes: 18 additions & 0 deletions config.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,24 @@ providers:
output_price: 15
cache_write_price: 3.75
cache_read_price: 0.30
# Example: per-(provider, upstream-model) extra_body for vendor-specific
# switches that are not part of the OpenAI Chat Completions schema. Only
# honored on the openai-chat protocol dispatch path; the configured map is
# merged into the top-level JSON of each outbound request to that specific
# offer. Standard fields (model/messages/stream/...) take precedence and
# cannot be clobbered. Sibling offers on the same provider are unaffected.
#
# qwen-dashscope:
# base_url: "https://dashscope.aliyuncs.com/compatible-mode/v1"
# api_key: "replace-with-dashscope-api-key"
# protocol: openai-chat
# offers:
# - model: qwen3-plus
# overrides:
# extra_body:
# enable_search: true
# - model: deepseek-v4-pro
# # No overrides.extra_body → no vendor switches injected.

routes:
# Optional aliases. Provider models are listed directly in the Codex catalog
Expand Down
4 changes: 4 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ providers:
output_price: 8
cache_write_price: 1
cache_read_price: 0.25
overrides:
# 供应商私有顶层 JSON 字段(仅 openai-chat 协议)。作用域为当前 offer。
extra_body:
enable_search: true
```

### Protocol 类型
Expand Down
12 changes: 12 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,12 @@ type ModelMeta struct {
// WebSearch holds model-level web search config (overrides provider-level).
WebSearch WebSearchConfig
Extensions map[string]ExtensionSettings
// ExtraBody carries vendor-specific top-level JSON switches that the
// openai-chat protocol dispatcher merges into the outbound Chat Completions
// request body (e.g. {"enable_search": true} for Qwen/DashScope). Scoped
// per (provider, upstream-model) tuple — sourced from
// providers.<key>.offers[].overrides.extra_body in YAML.
ExtraBody map[string]any
}

// ModelPricing holds per-provider model pricing.
Expand Down Expand Up @@ -200,6 +206,12 @@ type ModelDef struct {
SupportsImageDetailOriginal bool
WebSearch WebSearchConfig
Extensions map[string]ExtensionSettings
// ExtraBody carries vendor-specific top-level JSON switches. On ModelDef
// it exists only as the intermediate type used by the offer-override merge
// pipeline; it is not exposed at the top-level `models:` YAML segment.
// Final propagation target is ModelMeta.ExtraBody, populated per
// (provider, upstream-model) tuple via providers.<key>.offers[].overrides.extra_body.
ExtraBody map[string]any
}

// OfferEntry declares that a provider offers a model defined in Models.
Expand Down
7 changes: 7 additions & 0 deletions internal/config/config_loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ type ModelDefFileConfig struct {
SupportsImageDetailOriginal *bool `yaml:"supports_image_detail_original,omitempty" json:"supports_image_detail_original,omitempty"`
WebSearch WebSearchFileConfig `yaml:"web_search,omitempty" json:"web_search,omitempty"`
Extensions map[string]ExtensionFileConfig `yaml:"extensions,omitempty" json:"extensions,omitempty"`
ExtraBody map[string]any `yaml:"extra_body,omitempty" json:"extra_body,omitempty"`
}

type OfferFileConfig struct {
Expand Down Expand Up @@ -611,6 +612,9 @@ func mergeModelDefOverrides(base ModelDef, override ModelDefFileConfig) ModelDef
}
}
}
if len(override.ExtraBody) > 0 {
out.ExtraBody = cloneAnyMap(override.ExtraBody)
}
return out
}

Expand Down Expand Up @@ -651,6 +655,9 @@ func applyModelOverrides(meta *ModelMeta, override ModelDef) {
if override.SupportsImageDetailOriginal {
meta.SupportsImageDetailOriginal = true
}
if len(override.ExtraBody) > 0 {
meta.ExtraBody = cloneAnyMap(override.ExtraBody)
}
}

// buildRoutes parses route specs and merges model metadata.
Expand Down
76 changes: 76 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1074,3 +1074,79 @@ routes:
t.Fatalf("DisplayName = %q, want \"My Custom Display Name\"", route.DisplayName)
}
}

// TestLoadFromYAMLParsesOfferExtraBody verifies that vendor-specific top-level
// JSON switches written under providers.<key>.offers[].overrides.extra_body are
// propagated to the corresponding ModelMeta in cfg.ProviderDefs[provider].Models[upstream].
//
// This is the (provider, upstream-model) tuple granularity: two different offers
// on the same provider, and the same model name offered by two different providers,
// each carry independent ExtraBody maps.
func TestLoadFromYAMLParsesOfferExtraBody(t *testing.T) {
cfg, err := config.LoadFromYAML([]byte(`
mode: Transform
models:
qwen3-plus:
context_window: 1000000
deepseek-v4-pro:
context_window: 1000000
providers:
aliyun:
base_url: https://dashscope.aliyuncs.com/compatible-mode/v1
api_key: aliyun-key
protocol: openai-chat
offers:
- model: qwen3-plus
overrides:
extra_body:
enable_search: true
extra_flag: 1
- model: deepseek-v4-pro
bailian:
base_url: https://bailian.example.test/v1
api_key: bailian-key
protocol: openai-chat
offers:
- model: qwen3-plus
overrides:
extra_body:
enable_search: false
routes:
qwen:
model: qwen3-plus
provider: aliyun
`))
if err != nil {
t.Fatalf("LoadFromYAML() error = %v", err)
}

aliyunQwen, ok := cfg.ProviderDefs["aliyun"].Models["qwen3-plus"]
if !ok {
t.Fatalf("ProviderDefs[aliyun].Models[qwen3-plus] missing")
}
if aliyunQwen.ExtraBody["enable_search"] != true {
t.Errorf("aliyun/qwen3-plus ExtraBody[enable_search] = %v, want true", aliyunQwen.ExtraBody["enable_search"])
}
if aliyunQwen.ExtraBody["extra_flag"] != 1 {
t.Errorf("aliyun/qwen3-plus ExtraBody[extra_flag] = %v, want 1", aliyunQwen.ExtraBody["extra_flag"])
}

aliyunDeepseek, ok := cfg.ProviderDefs["aliyun"].Models["deepseek-v4-pro"]
if !ok {
t.Fatalf("ProviderDefs[aliyun].Models[deepseek-v4-pro] missing")
}
if len(aliyunDeepseek.ExtraBody) != 0 {
t.Errorf("aliyun/deepseek-v4-pro ExtraBody = %+v, want empty when no overrides.extra_body is set", aliyunDeepseek.ExtraBody)
}

bailianQwen, ok := cfg.ProviderDefs["bailian"].Models["qwen3-plus"]
if !ok {
t.Fatalf("ProviderDefs[bailian].Models[qwen3-plus] missing")
}
if bailianQwen.ExtraBody["enable_search"] != false {
t.Errorf("bailian/qwen3-plus ExtraBody[enable_search] = %v, want false (independent from aliyun's same-name offer)", bailianQwen.ExtraBody["enable_search"])
}
if _, hasExtraFlag := bailianQwen.ExtraBody["extra_flag"]; hasExtraFlag {
t.Errorf("bailian/qwen3-plus should not carry aliyun's extra_flag key — per-(provider, model) isolation broken")
}
}
4 changes: 4 additions & 0 deletions internal/config/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,10 @@ func toModelDefFileConfig(def ModelDef) ModelDefFileConfig {
}
}

if len(def.ExtraBody) > 0 {
m.ExtraBody = cloneAnyMap(def.ExtraBody)
}

return m
}

Expand Down
20 changes: 20 additions & 0 deletions internal/protocol/chat/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,29 @@ func (a *ChatProviderAdapter) FromCoreRequest(ctx context.Context, req *format.C
}
}

// extra_body: vendor-specific top-level JSON switches (e.g. enable_search)
// sourced from CoreRequest.Extensions["openai_chat"]["extra_body"].
// The dispatcher writes this value once per request based on the resolved
// (provider, upstream-model) tuple. ChatRequest.MarshalJSON flattens it to
// the top-level JSON object of the outbound body.
if extra := extractChatExtraBody(req.Extensions); len(extra) > 0 {
chatReq.ExtraParams = extra
}

return chatReq, nil
}

// extractChatExtraBody returns the openai-chat extra_body map carried on a
// CoreRequest, or nil when the key is absent or has the wrong shape.
func extractChatExtraBody(ext map[string]any) map[string]any {
bag, ok := ext["openai_chat"].(map[string]any)
if !ok {
return nil
}
extra, _ := bag["extra_body"].(map[string]any)
return extra
}

// =========================================================================
// ToCoreResponse — *ChatResponse → *CoreResponse
// =========================================================================
Expand Down
Loading