diff --git a/cmd/homed/main.go b/cmd/homed/main.go index aa95962..04cc3be 100644 --- a/cmd/homed/main.go +++ b/cmd/homed/main.go @@ -277,6 +277,10 @@ func main() { providerMgr := agentAPI.NewProviderManager() for _, src := range cfg.LLM.Sources { + if !agentAPI.IsValidSourceConfig(src.Name, src.BaseURL, src.Model, src.Adapter) { + log.Printf("[homed] skip invalid llm source %q (base_url=%q model=%q adapter=%q)", src.Name, src.BaseURL, src.Model, src.Adapter) + continue + } key := src.APIKey if key == "" { key = baseAPIKey @@ -513,12 +517,12 @@ func main() { } } return &ipc.Status{ - PID: os.Getpid(), - Boot: *boot, + PID: os.Getpid(), + Boot: *boot, UptimeSec: uptime, - LLMOK: &llmOK, - Tools: tools, - LastDiag: lastDiagSummary(*dataDir), + LLMOK: &llmOK, + Tools: tools, + LastDiag: lastDiagSummary(*dataDir), } }) if err := ipcServer.Start(); err != nil { diff --git a/internal/agent/api/provider.go b/internal/agent/api/provider.go index 275a933..4b378ef 100644 --- a/internal/agent/api/provider.go +++ b/internal/agent/api/provider.go @@ -129,9 +129,9 @@ type ToolCall struct { } type apiToolCall struct { - ID string `json:"id"` - Type string `json:"type"` - Function apiFunction `json:"function"` + ID string `json:"id"` + Type string `json:"type"` + Function apiFunction `json:"function"` } type apiFunction struct { @@ -140,9 +140,11 @@ type apiFunction struct { } type StreamChunk struct { - Content string `json:"content"` - Done bool `json:"done"` - ToolCall *ToolCall `json:"tool_call,omitempty"` + Content string `json:"content"` + ReasoningContent string `json:"reasoning_content,omitempty"` + Done bool `json:"done"` + ToolCall *ToolCall `json:"tool_call,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` } type Provider interface { @@ -227,6 +229,19 @@ func NewLuaAdaptedProvider(cfg BaseConfig, vm *luaVM.VM, name, adapter string) * } } +func IsValidSourceConfig(name, baseURL, model, adapter string) bool { + return validConfigValue(name) && validConfigValue(baseURL) && validConfigValue(model) && validConfigValue(adapter) && + (strings.HasPrefix(baseURL, "http://") || strings.HasPrefix(baseURL, "https://")) +} + +func validConfigValue(s string) bool { + s = strings.TrimSpace(s) + if s == "" { + return false + } + return !strings.EqualFold(s, "") && !strings.EqualFold(s, "null") && !strings.EqualFold(s, "nil") +} + func (p *LuaAdaptedProvider) MaxContextTokens() int { if p.cfg.ContextWindow > 0 { return p.cfg.ContextWindow @@ -283,17 +298,174 @@ func (p *LuaAdaptedProvider) Chat(ctx context.Context, req *CompletionRequest) ( unifiedJSON, err := p.vm.CallTransformResponse(p.adapter, string(rawResp)) if err != nil { + if parsed, perr := parseOpenAICompatibleResponse(rawResp); perr == nil { + return parsed, nil + } return nil, fmt.Errorf("lua transform_response: %w", err) } var result CompletionResponse if err := json.Unmarshal([]byte(unifiedJSON), &result); err != nil { + if parsed, perr := parseOpenAICompatibleResponse(rawResp); perr == nil { + return parsed, nil + } return nil, fmt.Errorf("unmarshal unified response: %w (body: %s)", err, unifiedJSON) } return &result, nil } +func parseOpenAICompatibleResponse(raw []byte) (*CompletionResponse, error) { + var resp struct { + Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + } `json:"usage"` + Choices []struct { + FinishReason string `json:"finish_reason"` + Message struct { + Content interface{} `json:"content"` + ReasoningContent string `json:"reasoning_content"` + ToolCalls []openAIToolCall `json:"tool_calls"` + } `json:"message"` + } `json:"choices"` + } + if err := json.Unmarshal(raw, &resp); err != nil { + return nil, err + } + out := &CompletionResponse{ + TokenUsage: TokenUsage{ + Prompt: resp.Usage.PromptTokens, + Completion: resp.Usage.CompletionTokens, + Total: resp.Usage.TotalTokens, + }, + } + if len(resp.Choices) == 0 { + return out, nil + } + ch := resp.Choices[0] + out.FinishReason = ch.FinishReason + out.Content = stringifyContent(ch.Message.Content) + out.ReasoningContent = ch.Message.ReasoningContent + out.ToolCalls = normalizeOpenAIToolCalls(ch.Message.ToolCalls) + return out, nil +} + +type openAIToolCall struct { + ID string `json:"id"` + Type string `json:"type"` + Function struct { + Name string `json:"name"` + Arguments interface{} `json:"arguments"` + } `json:"function"` + Name string `json:"name"` + Arguments interface{} `json:"arguments"` +} + +func normalizeOpenAIToolCalls(raw []openAIToolCall) []ToolCall { + if len(raw) == 0 { + return nil + } + out := make([]ToolCall, 0, len(raw)) + for _, tc := range raw { + name := tc.Function.Name + argsRaw := tc.Function.Arguments + if name == "" { + name = tc.Name + argsRaw = tc.Arguments + } + if name == "" { + continue + } + typ := tc.Type + if typ == "" { + typ = "function" + } + out = append(out, ToolCall{ + ID: tc.ID, + Type: typ, + Name: name, + Arguments: parseToolArguments(argsRaw), + }) + } + return out +} + +func parseToolArguments(v interface{}) map[string]interface{} { + switch x := v.(type) { + case nil: + return map[string]interface{}{} + case map[string]interface{}: + return x + case string: + if strings.TrimSpace(x) == "" { + return map[string]interface{}{} + } + var m map[string]interface{} + if err := json.Unmarshal([]byte(x), &m); err == nil && m != nil { + return m + } + var any interface{} + if err := json.Unmarshal([]byte(x), &any); err == nil { + return map[string]interface{}{"value": any} + } + return map[string]interface{}{"raw": x} + default: + b, _ := json.Marshal(x) + var m map[string]interface{} + if err := json.Unmarshal(b, &m); err == nil && m != nil { + return m + } + return map[string]interface{}{"value": x} + } +} + +func stringifyContent(v interface{}) string { + switch x := v.(type) { + case nil: + return "" + case string: + return x + case []interface{}: + var b strings.Builder + for _, part := range x { + if m, ok := part.(map[string]interface{}); ok { + if text, ok := m["text"].(string); ok { + b.WriteString(text) + } + } + } + return b.String() + default: + b, _ := json.Marshal(x) + return string(b) + } +} + +func parseOpenAICompatibleStreamChunk(raw []byte) (StreamChunk, bool) { + var resp struct { + Choices []struct { + Delta struct { + Content interface{} `json:"content"` + ReasoningContent string `json:"reasoning_content"` + ToolCalls []openAIToolCall `json:"tool_calls"` + } `json:"delta"` + FinishReason *string `json:"finish_reason"` + } `json:"choices"` + } + if err := json.Unmarshal(raw, &resp); err != nil || len(resp.Choices) == 0 { + return StreamChunk{}, false + } + choice := resp.Choices[0] + return StreamChunk{ + Content: stringifyContent(choice.Delta.Content), + ReasoningContent: choice.Delta.ReasoningContent, + ToolCalls: normalizeOpenAIToolCalls(choice.Delta.ToolCalls), + Done: choice.FinishReason != nil, + }, true +} + func (p *LuaAdaptedProvider) ChatStream(ctx context.Context, req *CompletionRequest) (<-chan StreamChunk, error) { req.Model = p.cfg.Model req.Stream = true @@ -341,27 +513,15 @@ func (p *LuaAdaptedProvider) ChatStream(ctx context.Context, req *CompletionRequ // 尝试用 Lua 变换流块(如果 adapter 定义了 transform_stream_chunk) unified, err := p.vm.CallTransformStreamChunk(p.adapter, line) if err != nil || unified == line { - // 无流变换函数或变换透传,尝试标准 SSE 解析 - var raw struct { - Choices []struct { - Delta struct { - Content string `json:"content"` - } `json:"delta"` - FinishReason *string `json:"finish_reason"` - } `json:"choices"` - } - if err := json.Unmarshal([]byte(unified), &raw); err != nil { + // 无流变换函数或变换透传,尝试标准 OpenAI SSE 解析 + chunk, ok := parseOpenAICompatibleStreamChunk([]byte(line)) + if !ok { continue } - if len(raw.Choices) > 0 { - select { - case ch <- StreamChunk{ - Content: raw.Choices[0].Delta.Content, - Done: raw.Choices[0].FinishReason != nil, - }: - case <-ctx.Done(): - return - } + select { + case ch <- chunk: + case <-ctx.Done(): + return } continue } @@ -412,9 +572,9 @@ func (s *SSEScanner) Scan() bool { func (s *SSEScanner) Text() string { return s.pending } type providerStatus struct { - failCount int + failCount int unavailableUntil time.Time - permanent bool // 401/403 永久不可用,不自动恢复 + permanent bool // 401/403 永久不可用,不自动恢复 } type ProviderManager struct { diff --git a/internal/config/registry.go b/internal/config/registry.go index fc96300..49dea27 100644 --- a/internal/config/registry.go +++ b/internal/config/registry.go @@ -695,6 +695,14 @@ func (r *ConfigRegistry) GetBool(key string, defaultVal bool) bool { return b } +func validLLMEndpoint(s string) bool { + s = strings.TrimSpace(s) + if s == "" || strings.EqualFold(s, "") || strings.EqualFold(s, "nil") || strings.EqualFold(s, "null") { + return false + } + return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") +} + // ToConfig 从 config 表重建 *types.Config func (r *ConfigRegistry) ToConfig() *types.Config { cfg := &types.Config{} @@ -817,10 +825,12 @@ func (r *ConfigRegistry) ToConfig() *types.Config { } else { seen := make(map[string]bool) for _, src := range cfg.LLM.Sources { - if src.BaseURL != "" && !seen[src.BaseURL] { - seen[src.BaseURL] = true - cfg.Defaults.LLMEndpoints = append(cfg.Defaults.LLMEndpoints, src.BaseURL) + baseURL := strings.TrimSpace(src.BaseURL) + if !validLLMEndpoint(baseURL) || seen[baseURL] { + continue } + seen[baseURL] = true + cfg.Defaults.LLMEndpoints = append(cfg.Defaults.LLMEndpoints, baseURL) } } diff --git a/internal/lua/adapters/deepseek.lua b/internal/lua/adapters/deepseek.lua index bcc6e41..dab4410 100644 --- a/internal/lua/adapters/deepseek.lua +++ b/internal/lua/adapters/deepseek.lua @@ -45,14 +45,34 @@ function adapter.transform_response(raw_body) if type(ch.message.tool_calls) == "table" then local tcs = {} for _, tc in ipairs(ch.message.tool_calls) do - local args_ok, args = pcall(json.decode, tc["function"].arguments) - if not args_ok then args = {} end - table.insert(tcs, { - id = tc.id, - type = tc.type or "function", - name = tc["function"].name, - arguments = args - }) + local fn = tc["function"] + local name = tc.name + local raw_args = tc.arguments + if type(fn) == "table" then + name = fn.name or name + raw_args = fn.arguments or raw_args + end + local args = {} + if type(raw_args) == "table" then + args = raw_args + elseif type(raw_args) == "string" and raw_args ~= "" then + local args_ok, decoded = pcall(json.decode, raw_args) + if args_ok and type(decoded) == "table" then + args = decoded + elseif args_ok then + args = { value = decoded } + else + args = { raw = raw_args } + end + end + if name ~= nil and name ~= "" then + table.insert(tcs, { + id = tc.id, + type = tc.type or "function", + name = name, + arguments = args + }) + end end unified.tool_calls = tcs end diff --git a/internal/lua/adapters/github.lua b/internal/lua/adapters/github.lua index c27103b..1a61ae7 100644 --- a/internal/lua/adapters/github.lua +++ b/internal/lua/adapters/github.lua @@ -42,14 +42,34 @@ function adapter.transform_response(raw_body) if type(ch.message.tool_calls) == "table" then local tcs = {} for _, tc in ipairs(ch.message.tool_calls) do - local args_ok, args = pcall(json.decode, tc["function"].arguments) - if not args_ok then args = {} end - table.insert(tcs, { - id = tc.id, - type = tc.type or "function", - name = tc["function"].name, - arguments = args - }) + local fn = tc["function"] + local name = tc.name + local raw_args = tc.arguments + if type(fn) == "table" then + name = fn.name or name + raw_args = fn.arguments or raw_args + end + local args = {} + if type(raw_args) == "table" then + args = raw_args + elseif type(raw_args) == "string" and raw_args ~= "" then + local args_ok, decoded = pcall(json.decode, raw_args) + if args_ok and type(decoded) == "table" then + args = decoded + elseif args_ok then + args = { value = decoded } + else + args = { raw = raw_args } + end + end + if name ~= nil and name ~= "" then + table.insert(tcs, { + id = tc.id, + type = tc.type or "function", + name = name, + arguments = args + }) + end end unified.tool_calls = tcs end diff --git a/internal/lua/adapters/groq.lua b/internal/lua/adapters/groq.lua index 3d66033..e6b8ea1 100644 --- a/internal/lua/adapters/groq.lua +++ b/internal/lua/adapters/groq.lua @@ -41,14 +41,34 @@ function adapter.transform_response(raw_body) if type(ch.message.tool_calls) == "table" then local tcs = {} for _, tc in ipairs(ch.message.tool_calls) do - local args_ok, args = pcall(json.decode, tc["function"].arguments) - if not args_ok then args = {} end - table.insert(tcs, { - id = tc.id, - type = tc.type or "function", - name = tc["function"].name, - arguments = args - }) + local fn = tc["function"] + local name = tc.name + local raw_args = tc.arguments + if type(fn) == "table" then + name = fn.name or name + raw_args = fn.arguments or raw_args + end + local args = {} + if type(raw_args) == "table" then + args = raw_args + elseif type(raw_args) == "string" and raw_args ~= "" then + local args_ok, decoded = pcall(json.decode, raw_args) + if args_ok and type(decoded) == "table" then + args = decoded + elseif args_ok then + args = { value = decoded } + else + args = { raw = raw_args } + end + end + if name ~= nil and name ~= "" then + table.insert(tcs, { + id = tc.id, + type = tc.type or "function", + name = name, + arguments = args + }) + end end unified.tool_calls = tcs end diff --git a/internal/lua/adapters/kimicode.lua b/internal/lua/adapters/kimicode.lua new file mode 100644 index 0000000..f37e00a --- /dev/null +++ b/internal/lua/adapters/kimicode.lua @@ -0,0 +1,103 @@ +local adapter = {} + +adapter.name = "kimicode" +adapter.version = "1.0.0" +adapter.endpoint = "/v1/chat/completions" +adapter.headers = {} + +function adapter.transform_request(raw_body) + local ok, req = pcall(json.decode, raw_body) + if not ok then return raw_body end + req.model = req.model or "kimi-k2" + req.disable_thinking = nil + req.extra_body = nil + if req.messages then + for _, msg in ipairs(req.messages) do + msg.reasoning_content = nil + end + end + return json.encode(req) +end + +function adapter.transform_response(raw_body) + local ok, resp = pcall(json.decode, raw_body) + if not ok or resp == nil then return raw_body end + + local unified = { + content = "", + finish_reason = "", + token_usage = { prompt = 0, completion = 0, total = 0 } + } + if type(resp.usage) == "table" then + unified.token_usage.prompt = resp.usage.prompt_tokens or 0 + unified.token_usage.completion = resp.usage.completion_tokens or 0 + unified.token_usage.total = resp.usage.total_tokens or 0 + end + if type(resp.choices) == "table" and #resp.choices > 0 then + local ch = resp.choices[1] + if type(ch.message) == "table" then + unified.content = ch.message.content or "" + if ch.message.reasoning_content then + unified.reasoning_content = ch.message.reasoning_content + end + if type(ch.message.tool_calls) == "table" then + local tcs = {} + for _, tc in ipairs(ch.message.tool_calls) do + local fn = tc["function"] + local name = tc.name + local raw_args = tc.arguments + if type(fn) == "table" then + name = fn.name or name + raw_args = fn.arguments or raw_args + end + local args = {} + if type(raw_args) == "table" then + args = raw_args + elseif type(raw_args) == "string" and raw_args ~= "" then + local args_ok, decoded = pcall(json.decode, raw_args) + if args_ok and type(decoded) == "table" then + args = decoded + elseif args_ok then + args = { value = decoded } + else + args = { raw = raw_args } + end + end + if name ~= nil and name ~= "" then + table.insert(tcs, { + id = tc.id, + type = tc.type or "function", + name = name, + arguments = args + }) + end + end + unified.tool_calls = tcs + end + end + unified.finish_reason = ch.finish_reason or "" + end + return json.encode(unified) +end + +function adapter.transform_stream_chunk(raw_chunk) + local ok, chunk = pcall(json.decode, raw_chunk) + if not ok then return "" end + if not chunk.choices or #chunk.choices == 0 then return "" end + local delta = chunk.choices[1].delta or {} + local fr = chunk.choices[1].finish_reason + + local unified = { + content = delta.content or "", + done = (fr ~= nil) + } + if delta.reasoning_content then + unified.reasoning_content = delta.reasoning_content + end + if delta.tool_calls then + unified.tool_calls = delta.tool_calls + end + return json.encode(unified) +end + +return adapter diff --git a/internal/lua/adapters/mistral.lua b/internal/lua/adapters/mistral.lua index 763da2a..b4f7a70 100644 --- a/internal/lua/adapters/mistral.lua +++ b/internal/lua/adapters/mistral.lua @@ -41,14 +41,34 @@ function adapter.transform_response(raw_body) if type(ch.message.tool_calls) == "table" then local tcs = {} for _, tc in ipairs(ch.message.tool_calls) do - local args_ok, args = pcall(json.decode, tc["function"].arguments) - if not args_ok then args = {} end - table.insert(tcs, { - id = tc.id, - type = tc.type or "function", - name = tc["function"].name, - arguments = args - }) + local fn = tc["function"] + local name = tc.name + local raw_args = tc.arguments + if type(fn) == "table" then + name = fn.name or name + raw_args = fn.arguments or raw_args + end + local args = {} + if type(raw_args) == "table" then + args = raw_args + elseif type(raw_args) == "string" and raw_args ~= "" then + local args_ok, decoded = pcall(json.decode, raw_args) + if args_ok and type(decoded) == "table" then + args = decoded + elseif args_ok then + args = { value = decoded } + else + args = { raw = raw_args } + end + end + if name ~= nil and name ~= "" then + table.insert(tcs, { + id = tc.id, + type = tc.type or "function", + name = name, + arguments = args + }) + end end unified.tool_calls = tcs end diff --git a/internal/lua/adapters/openai.lua b/internal/lua/adapters/openai.lua index 648c9b6..4ef5b6c 100644 --- a/internal/lua/adapters/openai.lua +++ b/internal/lua/adapters/openai.lua @@ -45,14 +45,34 @@ function adapter.transform_response(raw_body) if type(ch.message.tool_calls) == "table" then local tcs = {} for _, tc in ipairs(ch.message.tool_calls) do - local args_ok, args = pcall(json.decode, tc["function"].arguments) - if not args_ok then args = {} end - table.insert(tcs, { - id = tc.id, - type = tc.type or "function", - name = tc["function"].name, - arguments = args - }) + local fn = tc["function"] + local name = tc.name + local raw_args = tc.arguments + if type(fn) == "table" then + name = fn.name or name + raw_args = fn.arguments or raw_args + end + local args = {} + if type(raw_args) == "table" then + args = raw_args + elseif type(raw_args) == "string" and raw_args ~= "" then + local args_ok, decoded = pcall(json.decode, raw_args) + if args_ok and type(decoded) == "table" then + args = decoded + elseif args_ok then + args = { value = decoded } + else + args = { raw = raw_args } + end + end + if name ~= nil and name ~= "" then + table.insert(tcs, { + id = tc.id, + type = tc.type or "function", + name = name, + arguments = args + }) + end end unified.tool_calls = tcs end @@ -71,10 +91,17 @@ function adapter.transform_stream_chunk(raw_chunk) local delta = chunk.choices[1].delta or {} local fr = chunk.choices[1].finish_reason - return json.encode({ + local unified = { content = delta.content or "", done = (fr ~= nil) - }) + } + if delta.reasoning_content then + unified.reasoning_content = delta.reasoning_content + end + if delta.tool_calls then + unified.tool_calls = delta.tool_calls + end + return json.encode(unified) end return adapter diff --git a/internal/sdk/llm_impl.go b/internal/sdk/llm_impl.go index 0f10a83..5262802 100644 --- a/internal/sdk/llm_impl.go +++ b/internal/sdk/llm_impl.go @@ -110,18 +110,21 @@ func (l *llmImpl) ReloadFromConfig() error { } l.mgr.Reset() for _, src := range cfg.LLM.Sources { + if !agentAPI.IsValidSourceConfig(src.Name, src.BaseURL, src.Model, src.Adapter) { + continue + } key := src.APIKey if key == "" { key = l.baseAPIKey } - provider := agentAPI.NewLuaAdaptedProvider(agentAPI.BaseConfig{ - Model: src.Model, - BaseURL: src.BaseURL, - APIKey: key, - Temperature: cfg.LLM.Temperature, - MaxTokens: cfg.LLM.MaxTokens, - ContextWindow: src.ContextWindow, - }, l.lua, src.Name, src.Adapter) + provider := agentAPI.NewLuaAdaptedProvider(agentAPI.BaseConfig{ + Model: src.Model, + BaseURL: src.BaseURL, + APIKey: key, + Temperature: cfg.LLM.Temperature, + MaxTokens: cfg.LLM.MaxTokens, + ContextWindow: src.ContextWindow, + }, l.lua, src.Name, src.Adapter) l.mgr.Register(src.Name, provider) } if cfg.LLM.Provider != "" {