feat: Introduce model alias resolution, enhanced configuration options, and improved OpenAI/Claude adapter handling for responses, embeddings, and tool calls.

This commit is contained in:
CJACK
2026-02-18 23:06:18 +08:00
parent 27ecb4b69b
commit 3a75b75ae0
28 changed files with 1665 additions and 183 deletions

View File

@@ -62,11 +62,33 @@ type Config struct {
Accounts []Account `json:"accounts,omitempty"`
ClaudeMapping map[string]string `json:"claude_mapping,omitempty"`
ClaudeModelMap map[string]string `json:"claude_model_mapping,omitempty"`
ModelAliases map[string]string `json:"model_aliases,omitempty"`
Compat CompatConfig `json:"compat,omitempty"`
Toolcall ToolcallConfig `json:"toolcall,omitempty"`
Responses ResponsesConfig `json:"responses,omitempty"`
Embeddings EmbeddingsConfig `json:"embeddings,omitempty"`
VercelSyncHash string `json:"_vercel_sync_hash,omitempty"`
VercelSyncTime int64 `json:"_vercel_sync_time,omitempty"`
AdditionalFields map[string]any `json:"-"`
}
type CompatConfig struct {
WideInputStrictOutput bool `json:"wide_input_strict_output,omitempty"`
}
type ToolcallConfig struct {
Mode string `json:"mode,omitempty"`
EarlyEmitConfidence string `json:"early_emit_confidence,omitempty"`
}
type ResponsesConfig struct {
StoreTTLSeconds int `json:"store_ttl_seconds,omitempty"`
}
type EmbeddingsConfig struct {
Provider string `json:"provider,omitempty"`
}
func (c Config) MarshalJSON() ([]byte, error) {
m := map[string]any{}
for k, v := range c.AdditionalFields {
@@ -84,6 +106,21 @@ func (c Config) MarshalJSON() ([]byte, error) {
if len(c.ClaudeModelMap) > 0 {
m["claude_model_mapping"] = c.ClaudeModelMap
}
if len(c.ModelAliases) > 0 {
m["model_aliases"] = c.ModelAliases
}
if c.Compat.WideInputStrictOutput {
m["compat"] = c.Compat
}
if strings.TrimSpace(c.Toolcall.Mode) != "" || strings.TrimSpace(c.Toolcall.EarlyEmitConfidence) != "" {
m["toolcall"] = c.Toolcall
}
if c.Responses.StoreTTLSeconds > 0 {
m["responses"] = c.Responses
}
if strings.TrimSpace(c.Embeddings.Provider) != "" {
m["embeddings"] = c.Embeddings
}
if c.VercelSyncHash != "" {
m["_vercel_sync_hash"] = c.VercelSyncHash
}
@@ -117,6 +154,26 @@ func (c *Config) UnmarshalJSON(b []byte) error {
if err := json.Unmarshal(v, &c.ClaudeModelMap); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err)
}
case "model_aliases":
if err := json.Unmarshal(v, &c.ModelAliases); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err)
}
case "compat":
if err := json.Unmarshal(v, &c.Compat); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err)
}
case "toolcall":
if err := json.Unmarshal(v, &c.Toolcall); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err)
}
case "responses":
if err := json.Unmarshal(v, &c.Responses); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err)
}
case "embeddings":
if err := json.Unmarshal(v, &c.Embeddings); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err)
}
case "_vercel_sync_hash":
if err := json.Unmarshal(v, &c.VercelSyncHash); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err)
@@ -141,6 +198,11 @@ func (c Config) Clone() Config {
Accounts: slices.Clone(c.Accounts),
ClaudeMapping: cloneStringMap(c.ClaudeMapping),
ClaudeModelMap: cloneStringMap(c.ClaudeModelMap),
ModelAliases: cloneStringMap(c.ModelAliases),
Compat: c.Compat,
Toolcall: c.Toolcall,
Responses: c.Responses,
Embeddings: c.Embeddings,
VercelSyncHash: c.VercelSyncHash,
VercelSyncTime: c.VercelSyncTime,
AdditionalFields: map[string]any{},
@@ -490,3 +552,59 @@ func (s *Store) ClaudeMapping() map[string]string {
}
return map[string]string{"fast": "deepseek-chat", "slow": "deepseek-reasoner"}
}
func (s *Store) ModelAliases() map[string]string {
s.mu.RLock()
defer s.mu.RUnlock()
out := DefaultModelAliases()
for k, v := range s.cfg.ModelAliases {
key := strings.TrimSpace(lower(k))
val := strings.TrimSpace(lower(v))
if key == "" || val == "" {
continue
}
out[key] = val
}
return out
}
func (s *Store) CompatWideInputStrictOutput() bool {
// Current default policy is always wide-input / strict-output.
// Kept as a method so callers do not depend on storage shape.
return true
}
func (s *Store) ToolcallMode() string {
s.mu.RLock()
defer s.mu.RUnlock()
mode := strings.TrimSpace(strings.ToLower(s.cfg.Toolcall.Mode))
if mode == "" {
return "feature_match"
}
return mode
}
func (s *Store) ToolcallEarlyEmitConfidence() string {
s.mu.RLock()
defer s.mu.RUnlock()
level := strings.TrimSpace(strings.ToLower(s.cfg.Toolcall.EarlyEmitConfidence))
if level == "" {
return "high"
}
return level
}
func (s *Store) ResponsesStoreTTLSeconds() int {
s.mu.RLock()
defer s.mu.RUnlock()
if s.cfg.Responses.StoreTTLSeconds > 0 {
return s.cfg.Responses.StoreTTLSeconds
}
return 900
}
func (s *Store) EmbeddingsProvider() string {
s.mu.RLock()
defer s.mu.RUnlock()
return strings.TrimSpace(s.cfg.Embeddings.Provider)
}

View File

@@ -0,0 +1,44 @@
package config
import "testing"
func TestResolveModelDirectDeepSeek(t *testing.T) {
got, ok := ResolveModel(nil, "deepseek-chat")
if !ok || got != "deepseek-chat" {
t.Fatalf("expected deepseek-chat, got ok=%v model=%q", ok, got)
}
}
func TestResolveModelAlias(t *testing.T) {
got, ok := ResolveModel(nil, "gpt-4.1")
if !ok || got != "deepseek-chat" {
t.Fatalf("expected alias gpt-4.1 -> deepseek-chat, got ok=%v model=%q", ok, got)
}
}
func TestResolveModelHeuristicReasoner(t *testing.T) {
got, ok := ResolveModel(nil, "o3-super")
if !ok || got != "deepseek-reasoner" {
t.Fatalf("expected heuristic reasoner, got ok=%v model=%q", ok, got)
}
}
func TestResolveModelUnknown(t *testing.T) {
_, ok := ResolveModel(nil, "totally-custom-model")
if ok {
t.Fatal("expected unknown model to fail resolve")
}
}
func TestClaudeModelsResponsePaginationFields(t *testing.T) {
resp := ClaudeModelsResponse()
if _, ok := resp["first_id"]; !ok {
t.Fatalf("expected first_id in response: %#v", resp)
}
if _, ok := resp["last_id"]; !ok {
t.Fatalf("expected last_id in response: %#v", resp)
}
if _, ok := resp["has_more"]; !ok {
t.Fatalf("expected has_more in response: %#v", resp)
}
}

View File

@@ -1,5 +1,7 @@
package config
import "strings"
type ModelInfo struct {
ID string `json:"id"`
Object string `json:"object"`
@@ -71,6 +73,91 @@ func GetModelConfig(model string) (thinking bool, search bool, ok bool) {
}
}
func IsSupportedDeepSeekModel(model string) bool {
_, _, ok := GetModelConfig(model)
return ok
}
func DefaultModelAliases() map[string]string {
return map[string]string{
"gpt-4o": "deepseek-chat",
"gpt-4.1": "deepseek-chat",
"gpt-4.1-mini": "deepseek-chat",
"gpt-4.1-nano": "deepseek-chat",
"gpt-5": "deepseek-chat",
"gpt-5-mini": "deepseek-chat",
"gpt-5-codex": "deepseek-reasoner",
"o1": "deepseek-reasoner",
"o1-mini": "deepseek-reasoner",
"o3": "deepseek-reasoner",
"o3-mini": "deepseek-reasoner",
"claude-sonnet-4-5": "deepseek-chat",
"claude-haiku-4-5": "deepseek-chat",
"claude-opus-4-6": "deepseek-reasoner",
"claude-3-5-sonnet": "deepseek-chat",
"claude-3-5-haiku": "deepseek-chat",
"claude-3-opus": "deepseek-reasoner",
"gemini-2.5-pro": "deepseek-chat",
"gemini-2.5-flash": "deepseek-chat",
"llama-3.1-70b-instruct": "deepseek-chat",
"qwen-max": "deepseek-chat",
}
}
func ResolveModel(store *Store, requested string) (string, bool) {
model := lower(strings.TrimSpace(requested))
if model == "" {
return "", false
}
if IsSupportedDeepSeekModel(model) {
return model, true
}
aliases := DefaultModelAliases()
if store != nil {
for k, v := range store.ModelAliases() {
aliases[lower(strings.TrimSpace(k))] = lower(strings.TrimSpace(v))
}
}
if mapped, ok := aliases[model]; ok && IsSupportedDeepSeekModel(mapped) {
return mapped, true
}
if strings.HasPrefix(model, "deepseek-") {
return "", false
}
knownFamily := false
for _, prefix := range []string{
"gpt-", "o1", "o3", "claude-", "gemini-", "llama-", "qwen-", "mistral-", "command-",
} {
if strings.HasPrefix(model, prefix) {
knownFamily = true
break
}
}
if !knownFamily {
return "", false
}
useReasoner := strings.Contains(model, "reason") ||
strings.Contains(model, "reasoner") ||
strings.HasPrefix(model, "o1") ||
strings.HasPrefix(model, "o3") ||
strings.Contains(model, "opus") ||
strings.Contains(model, "r1")
useSearch := strings.Contains(model, "search")
switch {
case useReasoner && useSearch:
return "deepseek-reasoner-search", true
case useReasoner:
return "deepseek-reasoner", true
case useSearch:
return "deepseek-chat-search", true
default:
return "deepseek-chat", true
}
}
func lower(s string) string {
b := []byte(s)
for i, c := range b {
@@ -85,6 +172,28 @@ func OpenAIModelsResponse() map[string]any {
return map[string]any{"object": "list", "data": DeepSeekModels}
}
func ClaudeModelsResponse() map[string]any {
return map[string]any{"object": "list", "data": ClaudeModels}
func OpenAIModelByID(store *Store, id string) (ModelInfo, bool) {
canonical, ok := ResolveModel(store, id)
if !ok {
return ModelInfo{}, false
}
for _, model := range DeepSeekModels {
if model.ID == canonical {
return model, true
}
}
return ModelInfo{}, false
}
func ClaudeModelsResponse() map[string]any {
resp := map[string]any{"object": "list", "data": ClaudeModels}
if len(ClaudeModels) > 0 {
resp["first_id"] = ClaudeModels[0].ID
resp["last_id"] = ClaudeModels[len(ClaudeModels)-1].ID
} else {
resp["first_id"] = nil
resp["last_id"] = nil
}
resp["has_more"] = false
return resp
}