diff --git a/Taskfile.curl.yml b/Taskfile.curl.yml index 9abf0e6e1..f682fc1a5 100644 --- a/Taskfile.curl.yml +++ b/Taskfile.curl.yml @@ -19,6 +19,13 @@ tasks: cmds: - echo "{{.GREETING}}" silent: true + + model-list: + cmds: + - | + curl -s -X GET "http://localhost:12580/tingly/openai/v1/models" \ + -H "Authorization: Bearer {{.TINGLY_BOX_KEY}}" \ + -H "Content-Type: application/json" codex-image-gen: cmds: diff --git a/frontend/src/types/model.ts b/frontend/src/types/model.ts index 945e9590a..564c94fe5 100644 --- a/frontend/src/types/model.ts +++ b/frontend/src/types/model.ts @@ -3,15 +3,22 @@ */ // OpenAI format models +export interface ModelDetail { + description?: string; + context?: number; + max_tokens?: number; + max_completion_tokens?: number; + input_modalities?: string[]; + output_modalities?: string[]; + auth_type?: string; +} + export interface OpenAIModel { id: string; object: string; created: number; owned_by: string; - description?: string; - context?: number; - max_output?: number; - auth_type?: string; + detail?: ModelDetail; } export interface OpenAIModelsResponse { @@ -25,53 +32,9 @@ export interface AnthropicModel { created_at: string; display_name: string; type: string; - capabilities?: ModelCapabilities; max_input_tokens?: number; max_tokens?: number; - description?: string; - auth_type?: string; -} - -export interface ModelCapabilities { - batch: CapabilitySupport; - citations: CapabilitySupport; - code_execution: CapabilitySupport; - context_management?: ContextManagementCapability; - effort?: EffortCapability; - image_input: CapabilitySupport; - pdf_input: CapabilitySupport; - structured_outputs: CapabilitySupport; - thinking?: ThinkingCapability; -} - -export interface CapabilitySupport { - supported: boolean; -} - -export interface ContextManagementCapability { - supported: boolean; - clear_thinking_20251015?: CapabilitySupport; - clear_tool_uses_20250919?: CapabilitySupport; - compact_20260112?: CapabilitySupport; -} - -export interface EffortCapability { - supported: boolean; - low?: CapabilitySupport; - medium?: CapabilitySupport; - high?: CapabilitySupport; - xhigh?: CapabilitySupport; - max?: CapabilitySupport; -} - -export interface ThinkingCapability { - supported: boolean; - types?: ThinkingTypes; -} - -export interface ThinkingTypes { - adaptive: CapabilitySupport; - enabled: CapabilitySupport; + detail?: ModelDetail; } export interface AnthropicModelsResponse { diff --git a/internal/data/provider_template.go b/internal/data/provider_template.go index 5089cf1f5..6b1053964 100644 --- a/internal/data/provider_template.go +++ b/internal/data/provider_template.go @@ -34,7 +34,7 @@ type ModelInfo struct { ID string `json:"id"` Description string `json:"description,omitempty"` Context int `json:"context,omitempty"` - MaxOutput int `json:"max_output,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` } // NamingRules defines the naming conventions for provider IDs @@ -734,7 +734,7 @@ func (tm *TemplateManager) findEmbeddedTemplateByProvider(provider *typ.Provider // using the provider templates. If templates are not available, falls back to // the global default. // It checks in order: -// 1. Exact match in Models array (ModelInfo.MaxOutput) +// 1. Exact match in Models array (ModelInfo.MaxTokens) // 2. ModelCapacities override (for capacity-based limits) // 3. Global default func (tm *TemplateManager) GetMaxTokensForModel(provider, model string) int { @@ -744,8 +744,8 @@ func (tm *TemplateManager) GetMaxTokensForModel(provider, model string) int { if tmpl != nil { // NEW: Check Models array for MaxOutput for _, m := range tmpl.Models { - if m.ID == model && m.MaxOutput > 0 { - return m.MaxOutput + if m.ID == model && m.MaxTokens > 0 { + return m.MaxTokens } } // Fallback to ModelCapacities (for capacity-based limits) @@ -774,8 +774,8 @@ func (tm *TemplateManager) GetMaxTokensForModelByProvider(provider *typ.Provider if tmpl != nil { // NEW: Check Models array for MaxOutput for _, m := range tmpl.Models { - if m.ID == model && m.MaxOutput > 0 { - return m.MaxOutput + if m.ID == model && m.MaxTokens > 0 { + return m.MaxTokens } } // Fallback to ModelCapacities (for capacity-based limits) diff --git a/internal/data/providers.json b/internal/data/providers.json index 2e9e3b1f5..c73816988 100644 --- a/internal/data/providers.json +++ b/internal/data/providers.json @@ -12,7 +12,7 @@ "region": "cn | intl | global", "plan": "standard | coding | oauth" }, - "models_schema": "每条模型 { id, context?, max_output? },仅填写已验证的数值,不确定的省略字段或整个数组留空" + "models_schema": "每条模型 { id, context?, max_tokens? },仅填写已验证的数值,不确定的省略字段或整个数组留空" }, "version": "2.0.1", "last_updated": "2026-06-09T00:00:00Z", @@ -38,47 +38,47 @@ { "id": "gpt-3.5-turbo", "context": 16385, - "max_output": 4096 + "max_tokens": 4096 }, { "id": "gpt-4", "context": 8192, - "max_output": 8192 + "max_tokens": 8192 }, { "id": "gpt-4-turbo", "context": 128000, - "max_output": 4096 + "max_tokens": 4096 }, { "id": "gpt-4o", "context": 128000, - "max_output": 16384 + "max_tokens": 16384 }, { "id": "gpt-4o-mini", "context": 128000, - "max_output": 16384 + "max_tokens": 16384 }, { "id": "o1", "context": 200000, - "max_output": 100000 + "max_tokens": 100000 }, { "id": "o1-mini", "context": 128000, - "max_output": 65536 + "max_tokens": 65536 }, { "id": "o3", "context": 200000, - "max_output": 100000 + "max_tokens": 100000 }, { "id": "o3-mini", "context": 200000, - "max_output": 100000 + "max_tokens": 100000 } ], "supports_models_endpoint": true, @@ -106,83 +106,83 @@ { "id": "claude-3-haiku-20240307", "context": 200000, - "max_output": 4096 + "max_tokens": 4096 }, { "id": "claude-3-sonnet-20240229", "context": 200000, - "max_output": 4096 + "max_tokens": 4096 }, { "id": "claude-3-opus-20240229", "context": 200000, - "max_output": 4096 + "max_tokens": 4096 }, { "id": "claude-3-5-haiku-20241022", "context": 200000, - "max_output": 8192 + "max_tokens": 8192 }, { "id": "claude-3-5-sonnet-20241022", "context": 200000, - "max_output": 8192 + "max_tokens": 8192 }, { "id": "claude-3-7-sonnet-20250219", "context": 200000, - "max_output": 64000 + "max_tokens": 64000 }, { "id": "claude-opus-4-20250514", "context": 200000, - "max_output": 32000 + "max_tokens": 32000 }, { "id": "claude-sonnet-4-20250514", "context": 200000, - "max_output": 64000 + "max_tokens": 64000 }, { "id": "claude-opus-4-1-20250805", "context": 200000, - "max_output": 32000 + "max_tokens": 32000 }, { "id": "claude-sonnet-4-5-20250929", "context": 1000000, - "max_output": 64000 + "max_tokens": 64000 }, { "id": "claude-haiku-4-5-20251001", "context": 200000, - "max_output": 64000 + "max_tokens": 64000 }, { "id": "claude-opus-4-5-20251101", "context": 1000000, - "max_output": 64000 + "max_tokens": 64000 }, { "id": "claude-opus-4-6", "context": 1000000, - "max_output": 128000 + "max_tokens": 128000 }, { "id": "claude-sonnet-4-6", "context": 1000000, - "max_output": 64000 + "max_tokens": 64000 }, { "id": "claude-opus-4-7", "context": 1000000, - "max_output": 128000 + "max_tokens": 128000 }, { "id": "claude-fable-5", - "descritpion": "Limited time available", + "description": "Limited time available", "context": 1000000, - "max_output": 128000 + "max_tokens": 128000 } ], "supports_models_endpoint": true, @@ -210,52 +210,52 @@ { "id": "claude-sonnet-4-20250514", "context": 200000, - "max_output": 64000 + "max_tokens": 64000 }, { "id": "claude-opus-4-20250514", "context": 200000, - "max_output": 32000 + "max_tokens": 32000 }, { "id": "claude-opus-4-1-20250805", "context": 200000, - "max_output": 32000 + "max_tokens": 32000 }, { "id": "claude-sonnet-4-5-20250929", "context": 1000000, - "max_output": 64000 + "max_tokens": 64000 }, { "id": "claude-haiku-4-5-20251001", "context": 200000, - "max_output": 64000 + "max_tokens": 64000 }, { "id": "claude-opus-4-5-20251101", "context": 1000000, - "max_output": 64000 + "max_tokens": 64000 }, { "id": "claude-opus-4-6", "context": 1000000, - "max_output": 128000 + "max_tokens": 128000 }, { "id": "claude-sonnet-4-6", "context": 1000000, - "max_output": 64000 + "max_tokens": 64000 }, { "id": "claude-opus-4-7", "context": 1000000, - "max_output": 128000 + "max_tokens": 128000 }, { "id": "claude-opus-4-8", "context": 1000000, - "max_output": 128000 + "max_tokens": 128000 } ], "supports_models_endpoint": true, @@ -338,17 +338,17 @@ { "id": "gemini-1.5-flash", "context": 1000000, - "max_output": 8192 + "max_tokens": 8192 }, { "id": "gemini-1.5-pro", "context": 2000000, - "max_output": 8192 + "max_tokens": 8192 }, { "id": "gemini-2.0-flash", "context": 1000000, - "max_output": 8192 + "max_tokens": 8192 } ], "supports_models_endpoint": true, @@ -375,27 +375,27 @@ { "id": "gemini-2.5-pro", "context": 1048576, - "max_output": 65536 + "max_tokens": 65536 }, { "id": "gemini-2.5-flash", "context": 1048576, - "max_output": 65536 + "max_tokens": 65536 }, { "id": "gemini-2.5-flash-lite", "context": 1048576, - "max_output": 65536 + "max_tokens": 65536 }, { "id": "gemini-3.1-pro-preview", "context": 1000000, - "max_output": 65536 + "max_tokens": 65536 }, { "id": "gemini-3.5-flash", "context": 1000000, - "max_output": 65536 + "max_tokens": 65536 } ], "supports_models_endpoint": true, @@ -480,22 +480,22 @@ { "id": "grok-4-fast-reasoning", "context": 2000000, - "max_output": 30000 + "max_tokens": 30000 }, { "id": "grok-4-fast-non-reasoning", "context": 2000000, - "max_output": 30000 + "max_tokens": 30000 }, { "id": "grok-4-1-fast-reasoning", "context": 2000000, - "max_output": 30000 + "max_tokens": 30000 }, { "id": "grok-4-1-fast-non-reasoning", "context": 2000000, - "max_output": 30000 + "max_tokens": 30000 } ], "supports_models_endpoint": true, @@ -522,22 +522,22 @@ { "id": "deepseek-chat", "context": 128000, - "max_output": 8192 + "max_tokens": 8192 }, { "id": "deepseek-reasoner", "context": 128000, - "max_output": 65536 + "max_tokens": 65536 }, { "id": "deepseek-v4-flash", "context": 1000000, - "max_output": 384000 + "max_tokens": 384000 }, { "id": "deepseek-v4-pro", "context": 1000000, - "max_output": 384000 + "max_tokens": 384000 } ], "supports_models_endpoint": true, @@ -603,17 +603,17 @@ { "id": "qwen-max", "context": 32768, - "max_output": 8192 + "max_tokens": 8192 }, { "id": "qwen-plus", "context": 131072, - "max_output": 8192 + "max_tokens": 8192 }, { "id": "qwen-turbo", "context": 1000000, - "max_output": 8192 + "max_tokens": 8192 }, { "id": "qwen-long", @@ -644,17 +644,17 @@ { "id": "qwen-max", "context": 32768, - "max_output": 8192 + "max_tokens": 8192 }, { "id": "qwen-plus", "context": 131072, - "max_output": 8192 + "max_tokens": 8192 }, { "id": "qwen-turbo", "context": 1000000, - "max_output": 8192 + "max_tokens": 8192 }, { "id": "qwen-long", @@ -686,42 +686,42 @@ { "id": "qwen3-coder-plus", "context": 1048576, - "max_output": 65536 + "max_tokens": 65536 }, { "id": "qwen3.5-plus", "context": 1000000, - "max_output": 65536 + "max_tokens": 65536 }, { "id": "qwen3-max", "context": 262144, - "max_output": 65536 + "max_tokens": 65536 }, { "id": "qwen3-coder-next", "context": 262144, - "max_output": 65536 + "max_tokens": 65536 }, { "id": "glm-5", "context": 202752, - "max_output": 16384 + "max_tokens": 16384 }, { "id": "glm-4.7", "context": 202752, - "max_output": 16384 + "max_tokens": 16384 }, { "id": "minimax-m2.5", "context": 1048576, - "max_output": 32768 + "max_tokens": 32768 }, { "id": "kimi-k2.5", "context": 262144, - "max_output": 32768 + "max_tokens": 32768 } ], "supports_models_endpoint": false, @@ -748,42 +748,42 @@ { "id": "qwen3-coder-plus", "context": 1048576, - "max_output": 65536 + "max_tokens": 65536 }, { "id": "qwen3.5-plus", "context": 1000000, - "max_output": 65536 + "max_tokens": 65536 }, { "id": "qwen3-max", "context": 262144, - "max_output": 65536 + "max_tokens": 65536 }, { "id": "qwen3-coder-next", "context": 262144, - "max_output": 65536 + "max_tokens": 65536 }, { "id": "glm-5", "context": 202752, - "max_output": 16384 + "max_tokens": 16384 }, { "id": "glm-4.7", "context": 202752, - "max_output": 16384 + "max_tokens": 16384 }, { "id": "minimax-m2.5", "context": 1048576, - "max_output": 32768 + "max_tokens": 32768 }, { "id": "kimi-k2.5", "context": 262144, - "max_output": 32768 + "max_tokens": 32768 } ], "supports_models_endpoint": false, @@ -810,7 +810,7 @@ { "id": "qwen3-coder-plus", "context": 1000000, - "max_output": 65536 + "max_tokens": 65536 }, { "id": "qwen3-coder-flash" @@ -841,17 +841,17 @@ { "id": "kimi-for-coding", "context": 262144, - "max_output": 65536 + "max_tokens": 65536 }, { "id": "kimi-k2.6", "context": 262144, - "max_output": 65536 + "max_tokens": 65536 }, { "id": "kimi-k2.5", "context": 262144, - "max_output": 65536 + "max_tokens": 65536 } ], "supports_models_endpoint": false, @@ -902,22 +902,22 @@ { "id": "MiniMax-M2", "context": 204800, - "max_output": 131072 + "max_tokens": 131072 }, { "id": "MiniMax-M2.1", "context": 204800, - "max_output": 65536 + "max_tokens": 65536 }, { "id": "MiniMax-M2.1-lightning", "context": 204800, - "max_output": 65536 + "max_tokens": 65536 }, { "id": "MiniMax-M2.5", "context": 204800, - "max_output": 65536 + "max_tokens": 65536 } ], "supports_models_endpoint": false, @@ -944,22 +944,22 @@ { "id": "MiniMax-M2", "context": 204800, - "max_output": 131072 + "max_tokens": 131072 }, { "id": "MiniMax-M2.1", "context": 204800, - "max_output": 65536 + "max_tokens": 65536 }, { "id": "MiniMax-M2.1-lightning", "context": 204800, - "max_output": 65536 + "max_tokens": 65536 }, { "id": "MiniMax-M2.5", "context": 204800, - "max_output": 65536 + "max_tokens": 65536 } ], "supports_models_endpoint": false, @@ -986,22 +986,22 @@ { "id": "glm-4.5", "context": 128000, - "max_output": 96000 + "max_tokens": 96000 }, { "id": "glm-4.5-air", "context": 128000, - "max_output": 96000 + "max_tokens": 96000 }, { "id": "glm-4.6", "context": 200000, - "max_output": 128000 + "max_tokens": 128000 }, { "id": "glm-4.7", "context": 200000, - "max_output": 128000 + "max_tokens": 128000 } ], "supports_models_endpoint": true, @@ -1029,32 +1029,32 @@ { "id": "glm-5.1", "context": 204800, - "max_output": 131072 + "max_tokens": 131072 }, { "id": "glm-5-turbo", "context": 202752, - "max_output": 131072 + "max_tokens": 131072 }, { "id": "glm-5", "context": 202752, - "max_output": 131072 + "max_tokens": 131072 }, { "id": "glm-4.7", "context": 200000, - "max_output": 128000 + "max_tokens": 128000 }, { "id": "glm-4.6", "context": 200000, - "max_output": 128000 + "max_tokens": 128000 }, { "id": "glm-4.5-air", "context": 128000, - "max_output": 96000 + "max_tokens": 96000 } ], "supports_models_endpoint": true, @@ -1083,23 +1083,23 @@ { "id": "glm-4.5", "context": 128000, - "max_output": 96000 + "max_tokens": 96000 }, { "id": "glm-4.5-air", "context": 128000, - "max_output": 96000 + "max_tokens": 96000 }, { "id": "glm-4.6", "context": 200000, - "max_output": 128000 + "max_tokens": 128000 }, { "id": "glm-4.7", "description": "hi", "context": 200000, - "max_output": 128000 + "max_tokens": 128000 } ], "supports_models_endpoint": true, @@ -1128,32 +1128,32 @@ { "id": "glm-5.1", "context": 204800, - "max_output": 131072 + "max_tokens": 131072 }, { "id": "glm-5-turbo", "context": 202752, - "max_output": 131072 + "max_tokens": 131072 }, { "id": "glm-5", "context": 202752, - "max_output": 131072 + "max_tokens": 131072 }, { "id": "glm-4.7", "context": 200000, - "max_output": 128000 + "max_tokens": 128000 }, { "id": "glm-4.6", "context": 200000, - "max_output": 128000 + "max_tokens": 128000 }, { "id": "glm-4.5-air", "context": 128000, - "max_output": 96000 + "max_tokens": 96000 } ], "supports_models_endpoint": true, @@ -1193,7 +1193,7 @@ { "id": "kimi-k2.5", "context": 262144, - "max_output": 65535 + "max_tokens": 65535 } ], "supports_models_endpoint": true, @@ -1232,7 +1232,7 @@ { "id": "kimi-k2.5", "context": 262144, - "max_output": 65535 + "max_tokens": 65535 } ], "supports_models_endpoint": true, @@ -1259,7 +1259,7 @@ { "id": "kimi-for-coding", "context": 262144, - "max_output": 65536 + "max_tokens": 65536 } ], "supports_models_endpoint": false, @@ -1325,7 +1325,7 @@ { "id": "glm-4.7", "context": 200000, - "max_output": 128000 + "max_tokens": 128000 }, { "id": "deepseek-v3.2" @@ -1339,12 +1339,12 @@ { "id": "kimi-k2.5", "context": 262144, - "max_output": 65535 + "max_tokens": 65535 }, { "id": "minimax-m2.5", "context": 204800, - "max_output": 65536 + "max_tokens": 65536 } ], "supports_models_endpoint": false, @@ -1615,27 +1615,27 @@ { "id": "mimo-v2-flash", "context": 262144, - "max_output": 65536 + "max_tokens": 65536 }, { "id": "mimo-v2-omni", "context": 262144, - "max_output": 65536 + "max_tokens": 65536 }, { "id": "mimo-v2-pro", "context": 1048576, - "max_output": 131072 + "max_tokens": 131072 }, { "id": "mimo-v2.5", "context": 1048576, - "max_output": 131072 + "max_tokens": 131072 }, { "id": "mimo-v2.5-pro", "context": 1048576, - "max_output": 16384 + "max_tokens": 16384 } ], "supports_models_endpoint": true, diff --git a/internal/server/anthropic.go b/internal/server/anthropic.go index 3883d87d1..0f2487e18 100644 --- a/internal/server/anthropic.go +++ b/internal/server/anthropic.go @@ -15,70 +15,17 @@ import ( ) type ( - // CapabilitySupport indicates whether a capability is supported. - CapabilitySupport struct { - Supported bool `json:"supported"` - } - - // ContextManagementCapability describes context management support. - ContextManagementCapability struct { - Supported bool `json:"supported"` - ClearThinking20251015 CapabilitySupport `json:"clear_thinking_20251015,omitempty"` - ClearToolUses20250919 CapabilitySupport `json:"clear_tool_uses_20250919,omitempty"` - Compact20260112 CapabilitySupport `json:"compact_20260112,omitempty"` - } - - // EffortCapability describes reasoning_effort support and levels. - EffortCapability struct { - Supported bool `json:"supported"` - Low CapabilitySupport `json:"low,omitempty"` - Medium CapabilitySupport `json:"medium,omitempty"` - High CapabilitySupport `json:"high,omitempty"` - XHigh CapabilitySupport `json:"xhigh,omitempty"` - Max CapabilitySupport `json:"max,omitempty"` - } - - // ThinkingTypes describes supported thinking type configurations. - ThinkingTypes struct { - Adaptive CapabilitySupport `json:"adaptive,omitempty"` - Enabled CapabilitySupport `json:"enabled,omitempty"` - } - - // ThinkingCapability describes thinking support. - ThinkingCapability struct { - Supported bool `json:"supported"` - Types *ThinkingTypes `json:"types,omitempty"` - } - - // ModelCapabilities maps to Anthropic's ModelCapabilities in /v1/models. - ModelCapabilities struct { - Batch CapabilitySupport `json:"batch"` - Citations CapabilitySupport `json:"citations"` - CodeExecution CapabilitySupport `json:"code_execution"` - ContextManagement *ContextManagementCapability `json:"context_management,omitempty"` - Effort *EffortCapability `json:"effort,omitempty"` - ImageInput CapabilitySupport `json:"image_input"` - PDFInput CapabilitySupport `json:"pdf_input"` - StructuredOutputs CapabilitySupport `json:"structured_outputs"` - Thinking *ThinkingCapability `json:"thinking,omitempty"` - } - // AnthropicModel maps to Anthropic's native /v1/models response format. AnthropicModel struct { - ID string `json:"id"` - CreatedAt string `json:"created_at"` - DisplayName string `json:"display_name"` - Type string `json:"type"` - Capabilities *ModelCapabilities `json:"capabilities,omitempty"` - MaxInputTokens int `json:"max_input_tokens,omitempty"` - MaxTokens int `json:"max_tokens,omitempty"` - // Description is a tingly-box extension (not in Anthropic's wire format) - // consumed by the frontend to show model description in the model picker. - Description string `json:"description,omitempty"` - // AuthType is a tingly-box extension (not in Anthropic's wire format) - // consumed by the frontend to order model picker entries: - // oauth -> api_key -> vmodel. - AuthType string `json:"auth_type,omitempty"` + ID string `json:"id"` + CreatedAt string `json:"created_at"` + DisplayName string `json:"display_name"` + Type string `json:"type"` + // Anthropic native fields. + MaxInputTokens int `json:"max_input_tokens,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + // Detail contains tingly-box extensions shared with other model list formats. + Detail *ModelDetail `json:"detail,omitempty"` } AnthropicModelsResponse struct { Data []AnthropicModel `json:"data"` @@ -285,72 +232,35 @@ func (s *Server) anthropicListModelsWithScenario(c *gin.Context, scenario *typ.R if modelInfo.ID == rule.RequestModel { description = modelInfo.Description maxInputTokens = modelInfo.Context - maxTokens = modelInfo.MaxOutput + maxTokens = modelInfo.MaxTokens break } } } } - // Build capabilities from Anthropic API provider data - var caps *ModelCapabilities - if primaryProvider != nil && templateManager != nil { - if tmpl, err := templateManager.GetTemplate(primaryProvider.Name); err == nil && tmpl != nil { - // Only populate capabilities for known Anthropic providers - if tmpl.VendorFamily == "anthropic" { - yes := CapabilitySupport{Supported: true} - no := CapabilitySupport{Supported: false} - caps = &ModelCapabilities{ - Batch: no, - Citations: yes, - CodeExecution: yes, - ImageInput: yes, - PDFInput: yes, - StructuredOutputs: yes, - ContextManagement: &ContextManagementCapability{ - Supported: true, - ClearThinking20251015: yes, - ClearToolUses20250919: yes, - Compact20260112: yes, - }, - Effort: &EffortCapability{ - Supported: true, - Low: yes, - Medium: yes, - High: yes, - XHigh: yes, - Max: yes, - }, - Thinking: &ThinkingCapability{ - Supported: true, - Types: &ThinkingTypes{ - Adaptive: yes, - Enabled: yes, - }, - }, - } - } - } - } - - logrus.Warnf("We do not set capabilities, even set: %v", caps) - models = append(models, AnthropicModel{ - ID: rule.RequestModel, - CreatedAt: "2024-01-01T00:00:00Z", - DisplayName: displayName, - Type: "model", - // Capabilities: caps, + ID: rule.RequestModel, + CreatedAt: "2024-01-01T00:00:00Z", + DisplayName: displayName, + Type: "model", MaxInputTokens: maxInputTokens, MaxTokens: maxTokens, - Description: description, - AuthType: string(primaryAuthTypeForRule(cfg, rule)), + Detail: &ModelDetail{ + Description: description, + Context: maxInputTokens, + MaxTokens: maxTokens, + MaxCompletionTokens: maxTokens, + InputModalities: []string{"text"}, + OutputModalities: []string{"text"}, + AuthType: string(primaryAuthTypeForRule(cfg, rule)), + }, }) } sort.SliceStable(models, func(i, j int) bool { - return authTypeSortWeight(typ.AuthType(models[i].AuthType)) < - authTypeSortWeight(typ.AuthType(models[j].AuthType)) + return authTypeSortWeight(modelDetailAuthType(models[i].Detail)) < + authTypeSortWeight(modelDetailAuthType(models[j].Detail)) }) firstID := "" diff --git a/internal/server/model_list_detail_test.go b/internal/server/model_list_detail_test.go new file mode 100644 index 000000000..a6fe91d9c --- /dev/null +++ b/internal/server/model_list_detail_test.go @@ -0,0 +1,236 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/gin-gonic/gin" + "github.com/tingly-dev/tingly-box/internal/data" + "github.com/tingly-dev/tingly-box/internal/loadbalance" + "github.com/tingly-dev/tingly-box/internal/server/config" + "github.com/tingly-dev/tingly-box/internal/typ" +) + +func TestOpenAIListModelsPlacesExtensionsInDetail(t *testing.T) { + s := newModelListTestServer(t, typ.ScenarioOpenAI) + + body := performModelListRequest(t, func(c *gin.Context) { + s.openAIListModelsWithScenario(c, nil) + }) + + data := body["data"].([]interface{}) + if len(data) != 2 { + t.Fatalf("expected 2 models, got %d", len(data)) + } + + // OAuth-backed model should sort before API-key-backed model, using detail.auth_type. + first := data[0].(map[string]interface{}) + if first["id"] != "oauth-model" { + t.Fatalf("expected oauth model first, got %v", first["id"]) + } + + assertAbsent(t, first, "description") + assertAbsent(t, first, "context") + assertAbsent(t, first, "max_output") + assertAbsent(t, first, "max_tokens") + assertAbsent(t, first, "max_completion_tokens") + assertAbsent(t, first, "auth_type") + assertAbsent(t, first, "capabilities") + + detail := first["detail"].(map[string]interface{}) + assertEqual(t, detail, "description", "OAuth model") + assertEqual(t, detail, "context", float64(200000)) + assertEqual(t, detail, "max_tokens", float64(8192)) + assertEqual(t, detail, "max_completion_tokens", float64(8192)) + assertEqual(t, detail, "auth_type", string(typ.AuthTypeOAuth)) + assertStringSlice(t, detail["input_modalities"], []string{"text"}) + assertStringSlice(t, detail["output_modalities"], []string{"text"}) + assertAbsent(t, detail, "capabilities") +} + +func TestAnthropicListModelsKeepsNativeFieldsAndUsesDetailForExtensions(t *testing.T) { + s := newModelListTestServer(t, typ.ScenarioAnthropic) + + body := performModelListRequest(t, func(c *gin.Context) { + s.anthropicListModelsWithScenario(c, nil) + }) + + data := body["data"].([]interface{}) + if len(data) != 2 { + t.Fatalf("expected 2 models, got %d", len(data)) + } + + first := data[0].(map[string]interface{}) + if first["id"] != "oauth-model" { + t.Fatalf("expected oauth model first, got %v", first["id"]) + } + + assertEqual(t, first, "max_input_tokens", float64(200000)) + assertEqual(t, first, "max_tokens", float64(8192)) + assertAbsent(t, first, "description") + assertAbsent(t, first, "auth_type") + assertAbsent(t, first, "capabilities") + + detail := first["detail"].(map[string]interface{}) + assertEqual(t, detail, "description", "OAuth model") + assertEqual(t, detail, "context", float64(200000)) + assertEqual(t, detail, "max_tokens", float64(8192)) + assertEqual(t, detail, "max_completion_tokens", float64(8192)) + assertEqual(t, detail, "auth_type", string(typ.AuthTypeOAuth)) + assertStringSlice(t, detail["input_modalities"], []string{"text"}) + assertStringSlice(t, detail["output_modalities"], []string{"text"}) + assertAbsent(t, detail, "capabilities") +} + +func newModelListTestServer(t *testing.T, scenario typ.RuleScenario) *Server { + t.Helper() + + cfg, err := config.NewConfig(config.WithConfigDir(t.TempDir()), config.WithDisableMigration()) + if err != nil { + t.Fatalf("NewConfig error: %v", err) + } + + providers := []*typ.Provider{ + { + UUID: "api-provider", + Name: "api-template", + APIBase: "https://api.example.test", + AuthType: typ.AuthTypeAPIKey, + Enabled: true, + }, + { + UUID: "oauth-provider", + Name: "oauth-template", + APIBase: "https://oauth.example.test", + AuthType: typ.AuthTypeOAuth, + Enabled: true, + }, + } + for _, provider := range providers { + if err := cfg.AddProvider(provider); err != nil { + t.Fatalf("AddProvider(%s) error: %v", provider.UUID, err) + } + } + + cfg.Rules = []typ.Rule{ + { + UUID: "api-rule", + Scenario: scenario, + RequestModel: "api-model", + Services: []*loadbalance.Service{{ + Provider: "api-provider", + Model: "api-model", + Active: true, + }}, + Active: true, + }, + { + UUID: "oauth-rule", + Scenario: scenario, + RequestModel: "oauth-model", + Services: []*loadbalance.Service{{ + Provider: "oauth-provider", + Model: "oauth-model", + Active: true, + }}, + Active: true, + }, + } + cfg.SetTemplateManager(newModelListTestTemplateManager(t)) + + return &Server{config: cfg} +} + +func newModelListTestTemplateManager(t *testing.T) *data.TemplateManager { + t.Helper() + + templatePath := filepath.Join(t.TempDir(), "providers.json") + registry := `{ + "_schema_version": 2, + "version": "test", + "providers": { + "api-template": { + "id": "api-template", + "name": "api-template", + "status": "active", + "valid": true, + "models": [ + {"id": "api-model", "description": "API model", "context": 128000, "max_tokens": 4096} + ] + }, + "oauth-template": { + "id": "oauth-template", + "name": "oauth-template", + "status": "active", + "valid": true, + "models": [ + {"id": "oauth-model", "description": "OAuth model", "context": 200000, "max_tokens": 8192} + ] + } + } +}` + if err := os.WriteFile(templatePath, []byte(registry), 0o600); err != nil { + t.Fatalf("write template registry: %v", err) + } + + tm := data.NewTemplateManager("file://" + templatePath) + if _, err := tm.FetchTemplates(t.Context()); err != nil { + t.Fatalf("FetchTemplates error: %v", err) + } + return tm +} + +func performModelListRequest(t *testing.T, handler func(*gin.Context)) map[string]interface{} { + t.Helper() + + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil) + + handler(c) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + + var body map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("decode response: %v\nbody: %s", err, w.Body.String()) + } + return body +} + +func assertAbsent(t *testing.T, obj map[string]interface{}, key string) { + t.Helper() + if _, ok := obj[key]; ok { + t.Fatalf("expected %q to be absent in %#v", key, obj) + } +} + +func assertEqual(t *testing.T, obj map[string]interface{}, key string, want interface{}) { + t.Helper() + if got := obj[key]; got != want { + t.Fatalf("%s = %#v, want %#v", key, got, want) + } +} + +func assertStringSlice(t *testing.T, got interface{}, want []string) { + t.Helper() + items, ok := got.([]interface{}) + if !ok { + t.Fatalf("got %#v, want string slice", got) + } + if len(items) != len(want) { + t.Fatalf("slice length = %d, want %d (%#v)", len(items), len(want), got) + } + for i, item := range items { + if item != want[i] { + t.Fatalf("slice[%d] = %#v, want %#v", i, item, want[i]) + } + } +} diff --git a/internal/server/model_list_helper.go b/internal/server/model_list_helper.go index 99e7e8d07..8ab26ce13 100644 --- a/internal/server/model_list_helper.go +++ b/internal/server/model_list_helper.go @@ -54,3 +54,10 @@ func authTypeSortWeight(a typ.AuthType) int { return 1 } } + +func modelDetailAuthType(detail *ModelDetail) typ.AuthType { + if detail == nil { + return typ.AuthTypeAPIKey + } + return typ.AuthType(detail.AuthType) +} diff --git a/internal/server/openai.go b/internal/server/openai.go index 30d97dbab..93a1a7ad8 100644 --- a/internal/server/openai.go +++ b/internal/server/openai.go @@ -197,22 +197,26 @@ func (s *Server) openAIListModelsWithScenario(c *gin.Context, scenario *typ.Rule } // Build owned_by field - ownedBy := "tingly-box" + + var ownedBy string + if len(providerDesc) > 0 { - ownedBy += " via " + fmt.Sprintf("%v", providerDesc) + ownedBy = fmt.Sprintf("%v", providerDesc) + } else { + ownedBy = "tingly-box" } // Get model description from template if available var description string var context int - var maxOutput int + var maxTokens int if templateManager != nil && primaryProvider != nil { if tmpl, err := templateManager.GetTemplate(primaryProvider.Name); err == nil && tmpl != nil { for _, modelInfo := range tmpl.Models { if modelInfo.ID == rule.RequestModel { description = modelInfo.Description context = modelInfo.Context - maxOutput = modelInfo.MaxOutput + maxTokens = modelInfo.MaxTokens break } } @@ -220,20 +224,25 @@ func (s *Server) openAIListModelsWithScenario(c *gin.Context, scenario *typ.Rule } models = append(models, OpenAIModel{ - ID: rule.RequestModel, - Object: "model", - Created: created, - OwnedBy: ownedBy, - Description: description, - Context: context, - MaxOutput: maxOutput, - AuthType: string(primaryAuthTypeForRule(cfg, rule)), + ID: rule.RequestModel, + Object: "model", + Created: created, + OwnedBy: ownedBy, + Detail: &ModelDetail{ + Description: description, + Context: context, + MaxTokens: maxTokens, + MaxCompletionTokens: maxTokens, + InputModalities: []string{"text"}, + OutputModalities: []string{"text"}, + AuthType: string(primaryAuthTypeForRule(cfg, rule)), + }, }) } sort.SliceStable(models, func(i, j int) bool { - return authTypeSortWeight(typ.AuthType(models[i].AuthType)) < - authTypeSortWeight(typ.AuthType(models[j].AuthType)) + return authTypeSortWeight(modelDetailAuthType(models[i].Detail)) < + authTypeSortWeight(modelDetailAuthType(models[j].Detail)) }) c.JSON(http.StatusOK, OpenAIModelsResponse{ diff --git a/internal/server/server_types.go b/internal/server/server_types.go index 4a3c6ecc8..1f1b7804f 100644 --- a/internal/server/server_types.go +++ b/internal/server/server_types.go @@ -314,22 +314,38 @@ type FetchProviderModelsResponse struct { Data interface{} `json:"data"` } -// OpenAIModel represents a model in OpenAI's models API format -type OpenAIModel struct { - ID string `json:"id"` - Object string `json:"object"` - Created int64 `json:"created"` - OwnedBy string `json:"owned_by"` - Description string `json:"description,omitempty"` // Model description - Context int `json:"context,omitempty"` // Max context window - MaxOutput int `json:"max_output,omitempty"` // Max output tokens +// ModelDetail contains tingly-box model metadata that is shared across +// protocol-specific model list formats. Keep protocol-native model fields on +// the protocol struct and put reusable extensions here. +// +// BREAKING CHANGE: Previously, model metadata fields (description, context, +// max_output, auth_type) were flat on the model object. They are now nested +// under the "detail" field. Clients must be updated to read from the detail +// object: +// - Before: model.description, model.context, model.max_output, model.auth_type +// - After: model.detail.description, model.detail.context, model.detail.max_tokens, model.detail.auth_type +type ModelDetail struct { + Description string `json:"description,omitempty"` + Context int `json:"context,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + MaxCompletionTokens int `json:"max_completion_tokens,omitempty"` + InputModalities []string `json:"input_modalities,omitempty"` + OutputModalities []string `json:"output_modalities,omitempty"` // AuthType reflects the primary backing provider's auth type. It is - // non-standard (OpenAI's models API has no such field) and consumed by - // the tingly-box frontend to order model picker entries: + // consumed by the tingly-box frontend to order model picker entries: // oauth -> api_key -> vmodel. AuthType string `json:"auth_type,omitempty"` } +// OpenAIModel represents a model in OpenAI's models API format +type OpenAIModel struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + OwnedBy string `json:"owned_by"` + Detail *ModelDetail `json:"detail,omitempty"` +} + // OpenAIModelsResponse represents OpenAI's models API response format type OpenAIModelsResponse struct { Object string `json:"object"`