Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/ccfos_nightingale.json
T
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
2026-08-07 12:53:47 +08:00

1 line
100 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "ccfos/nightingale", "files": {"aiagent/llm/openai.go": {"7c59ee00c12a1cd5bc313dbb0655cd38e6dac6ca": {"t": 1785746052.62817, "n": 12841, "h": "792f713dacc8158d", "c": "package llm\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/toolkits/pkg/logger\"\n)\n\nconst DefaultOpenAIURL = \"https://api.openai.com/v1/chat/completions\"\n\n// maxRetries / initialRetryWait / maxRetryWait 迁移至 defaults.go\n\n// OpenAI implements the LLM interface for OpenAI and compatible APIs\ntype OpenAI struct {\n\tconfig *Config\n\tclient *http.Client\n}\n\n// NewOpenAI creates a new OpenAI provider\nfunc NewOpenAI(cfg *Config, client *http.Client) (*OpenAI, error) {\n\tif cfg.BaseURL == \"\" {\n\t\tcfg.BaseURL = DefaultOpenAIURL\n\t} else {\n\t\tcfg.BaseURL = NormalizeOpenAIURL(cfg.BaseURL)\n\t}\n\treturn &OpenAI{\n\t\tconfig: cfg,\n\t\tclient: client,\n\t}, nil\n}\n\n// NormalizeOpenAIURL 归一化 OpenAI 兼容端点:\n// 用户常见填法包括 `https://host/v1` 或 `https://host/compatible-mode/v1`,\n// 这里自动补 `/chat/completions`,避免漏路径时返回 404。\n// 对于已知需要 /v1/chat/completions 路径的提供商(如 DeepSeek),\n// 当用户只填写了根域名时自动补全完整路径。\nfunc NormalizeOpenAIURL(rawURL string) string {\n\tu := strings.TrimRight(rawURL, \"/\")\n\tif strings.HasSuffix(u, \"/chat/completions\") {\n\t\treturn u\n\t}\n\tif strings.HasSuffix(u, \"/v1\") {\n\t\treturn u + \"/chat/completions\"\n\t}\n\t// DeepSeek 使用标准 OpenAI 兼容路径,用户填写根域名时自动补全\n\tif u == \"https://api.deepseek.com\" || u == \"http://api.deepseek.com\" {\n\t\treturn u + \"/v1/chat/completions\"\n\t}\n\treturn u\n}\n\nfunc (o *OpenAI) Name() string {\n\treturn ProviderOpenAI\n}\n\n// OpenAI API request/response structures\ntype openAIRequest struct {\n\tModel string `json:\"model\"`\n\tMessages []openAIMessage `json:\"messages\"`\n\tTools []openAITool `json:\"tools,omitempty\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n\tTemperature float64 `json:\"temperature,omitempty\"`\n\tTopP float64 `json:\"top_p,omitempty\"`\n\tStream bool `json:\"stream,omitempty\"`\n\n\t// extraBody 不出现在 JSON tag 里——由 MarshalJSON 平铺到顶层。\n\t// 用于把 LLMConfig.ExtraBody 透传给厂商特定字段(如 dashscope 的 enable_thinking)。\n\textraBody map[string]any\n}\n\n// MarshalJSON 把 extraBody 平铺到顶层 JSON。逻辑:先用别名走默认序列化(拿到所有\n// 显式字段),unmarshal 回 map,再合并 extraBody,最后 marshal 出去。这种\"先反射\n// 再合并\"的写法多绕一步,但好处是新增显式字段时不用同步改任何 reflection 代码。\n// 已存在的 key 由显式字段优先(不让 extraBody 偷偷覆盖 model/messages 等关键字段)。\nfunc (r openAIRequest) MarshalJSON() ([]byte, error) {\n\ttype alias openAIRequest\n\tdata, err := json.Marshal(alias(r))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif len(r.extraBody) == 0 {\n\t\treturn data, nil\n\t}\n\tvar m map[string]any\n\tif err := json.Unmarshal(data, &m); err != nil {\n\t\treturn nil, err\n\t}\n\tfor k, v := range r.extraBody {\n\t\tif _, occupied := m[k]; occupied {\n\t\t\tcontinue\n\t\t}\n\t\tm[k] = v\n\t}\n\treturn json.Marshal(m)\n}\n\ntype openAIMessage struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content,omitempty\"`\n\tReasoningContent string `json:\"reasoning_content,omitempty\"`\n\tToolCalls []openAIToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string `json:\"tool_call_id,omitempty\"`\n}\n\ntype openAITool struct {\n\tType string `json:\"type\"`\n\tFunction openAIFunction `json:\"function\"`\n}\n\ntype openAIFunction struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tParameters map[string]interface{} `json:\"parameters,omitempty\"`\n}\n\ntype openAIToolCall struct {\n\t// Index 仅流式 delta 携带:同轮多个并行 tool_call 靠它区分归属。\n\t// 指针以区分\"未携带\"(部分网关)与合法的 index=0;请求侧序列化时省略。\n\tIndex *int `json:\"index,omitempty\"`\n\tID string `json:\"id\"`\n\tType string `json:\"type\"`\n\tFunction struct {\n\t\tName string `json:\"name\"`\n\t\tArguments string `json:\"arguments\"`\n\t} `json:\"function\"`\n}\n\ntype openAIResponse struct {\n\tID string `json:\"id\"`\n\tObject string `json:\"object\"`\n\tCreated int64 `json:\"created\"`\n\tModel string `json:\"model\"`\n\tChoices []struct {\n\t\tIndex int `json:\"index\"`\n\t\tMessage openAIMessage `json:\"message\"`\n\t\tDelta openAIMessage `json:\"delta\"`\n\t\tFinishReason string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tUsage *struct {\n\t\tPromptTokens int `json:\"prompt_tokens\"`\n\t\tCompletionTokens int `json:\"completion_tokens\"`\n\t\tTotalTokens int `json:\"total_tokens\"`\n\t} `json:\"usage,omitempty\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t\tType string `json:\"type\"`\n\t\tCode string `json:\"code\"`\n\t} `json:\"error,omitempty\"`\n}\n\nfunc (o *OpenAI) Generate(ctx context.Context, req *GenerateRequest) (*GenerateResponse, error) {\n\t// Convert to OpenAI format\n\topenAIReq := o.convertRequest(req)\n\topenAIReq.Stream = false\n\n\t// Make request\n\trespBody, err := o.doRequest(ctx, openAIReq)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Parse response\n\tvar openAIResp openAIResponse\n\tif err := json.Unmarshal(respBody, &openAIResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse response: %w\", err)\n\t}\n\n\tif openAIResp.Error != nil {\n\t\treturn nil, fmt.Errorf(\"OpenAI API error: %s\", openAIResp.Error.Message)\n\t}\n\n\tif len(openAIResp.Choices) == 0 {\n\t\treturn nil, fmt.Errorf(\"no response from OpenAI\")\n\t}\n\n\t// Convert to unified response\n\treturn o.convertResponse(&openAIResp), nil\n}\n\nfunc (o *OpenAI) GenerateStream(ctx context.Context, req *GenerateRequest) (<-chan StreamChunk, error) {\n\topenAIReq := o.convertRequest(req)\n\topenAIReq.Stream = true\n\n\tjsonData, err := json.Marshal(openAIReq)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to marshal request: %w\", err)\n\t}\n\t// Debug 日志:确认 extraBody 等扩展字段是否真的被序列化进请求体。\n\t// 只在 ExtraBody 非空时打印,避免常态噪音;只打前 512 字节防止泄漏长 prompt。\n\tif len(o.config.ExtraBody) > 0 {\n\t\tpreview := string(jsonData)\n\t\tif len(preview) > 512 {\n\t\t\tpreview = preview[:512] + \"...(truncated)\"\n\t\t}\n\t\tlogger.Debugf(\"[OpenAI] stream request body (with extra): %s\", preview)\n\t}\n\n\tresp, err := doHTTPStreamWithRetry(ctx, o.client, \"OpenAI\",\n\t\tfunc() (*http.Request, error) {\n\t\t\treturn http.NewRequestWithContext(ctx, \"POST\", o.config.BaseURL, bytes.NewBuffer(jsonData))\n\t\t},\n\t\to.setHeaders,\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tch := make(chan StreamChunk, 100)\n\tgo o.streamResponse(ctx, resp, ch)\n\treturn ch, nil\n}\n\n// openAIToolCallAggregator 按 index 聚合流式下发的 tool_call 分片。\n// OpenAI 流式协议里同轮多个并行调用靠 index 字段区分:规范只保证每个调用首块带\n// id+name,后续块只带 index+arguments 片段,且不同调用的片段可以交错下发;部分\n// 兼容网关(qwen/deepseek 等)还会在每个 delta 重发 id+name。这里统一按 index\n// 归槽累积,流结束时整块抛出(与 claude.go content_block_stop 的做法对齐),\n// 消费端无需感知分片细节。\ntype openAIToolCallAggregator struct {\n\tslots map[int]*ToolCall\n\torder []int // 首见顺序,flush 时按它输出,保持调用顺序稳定\n\tlast int // 最近分片归入的 index:兜底不带 index 的网关(\"纯参数片段 → 续接上一个\")\n}\n\nfunc (a *openAIToolCallAggregator) add(tc openAIToolCall) {\n\tvar idx int\n\tswitch {\n\tcase tc.Index != nil:\n\t\tidx = *tc.Index\n\tcase tc.ID == \"\" && tc.Function.Name == \"\" && len(a.order) > 0:\n\t\t// 无 index 的纯参数片段:续接最近一个调用(旧启发式,仅作兜底)\n\t\tidx = a.last\n\tdefault:\n\t\t// 无 index 但带 id/name → 新调用,合成一个不与已有槽冲突的 index\n\t\tidx = -1\n\t\tfor _, used := range a.order {\n\t\t\tif used >= idx {\n\t\t\t\tidx = used + 1\n\t\t\t}\n\t\t}\n\t\tif idx < 0 {\n\t\t\tidx = 0\n\t\t}\n\t}\n\n\tslot, ok := a.slots[idx]\n\tif !ok {\n\t\tif a.slots == nil {\n\t\t\ta.slots = map[int]*ToolCall{}\n\t\t}\n\t\tslot = &ToolCall{}\n\t\ta.slots[idx] = slot\n\t\ta.order = append(a.order, idx)\n\t}\n\t// id/name 只取首个非空值:网关重发的 id+name 不会再被误判成新调用\n\tif slot.ID == \"\" {\n\t\tslot.ID = tc.ID\n\t}\n\tif slot.Name == \"\" {\n\t\tslot.Name = tc.Function.Name\n\t}\n\tslot.Arguments += tc.Function.Arguments\n\ta.last = idx\n}\n\n// flush 按首见顺序吐出聚合完成的调用并清空状态(幂等,重复调用返回 nil)。\nfunc (a *openAIToolCallAggregator) flush() []ToolCall {\n\tif len(a.order) == 0 {\n\t\treturn nil\n\t}\n\tcalls := make([]ToolCall, 0, len(a.order))\n\tfor _, idx := range a.order {\n\t\tcalls = append(calls, *a.slots[idx])\n\t}\n\ta.slots = nil\n\ta.order = nil\n\treturn calls\n}\n\nfunc (o *OpenAI) streamResponse(ctx context.Context, resp *http.Response, ch chan<- StreamChunk) {\n\tdefer close(ch)\n\tdefer resp.Body.Close()\n\n\treader := bufio.NewReader(resp.Body)\n\tvar toolCalls openAIToolCallAggregator\n\n\t// finish 在流正常收尾时把聚合好的完整 tool_call 整块抛出后再发 Done;\n\t// 出错路径不 flush——参数片段不完整,吐出去只会变成损坏的 JSON 调用。\n\t// complete 区分两种收尾:[DONE] 表示协议走完,调用原样下发(模型自产的坏\n\t// JSON 也该进工具循环,靠错误观测喂回模型重试);EOF 兜底(网关没发 [DONE]\n\t// 就断流,如 idle timeout 干净 FIN)下连接可能停在 arguments 片段中途,\n\t// JSON 不完整的调用丢弃并告警——下游 unmarshal 失败会把残参包成\n\t// {\"input\": raw} 真执行,比丢调用更危险。空 arguments 视为完整(无参调用)。\n\tfinish := func(complete bool) {\n\t\tcalls := toolCalls.flush()\n\t\tif !complete {\n\t\t\tintact := calls[:0]\n\t\t\tfor _, call := range calls {\n\t\t\t\tif call.Arguments == \"\" || json.Valid([]byte(call.Arguments)) {\n\t\t\t\t\tintact = append(intact, call)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tlogger.Warningf(\"[OpenAI] stream ended without [DONE], drop tool call with truncated arguments: name=%s id=%s args_len=%d\", call.Name, call.ID, len(call.Arguments))\n\t\t\t}\n\t\t\tcalls = intact\n\t\t}\n\t\tif len(calls) > 0 {\n\t\t\tch <- StreamChunk{ToolCalls: calls}\n\t\t}\n\t\tch <- StreamChunk{Done: true}\n\t}\n\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\tch <- StreamChunk{Done: true, Error: ctx.Err()}\n\t\t\treturn\n\t\tdefault:\n\t\t}\n\n\t\tline, err := reader.ReadString('\\n')\n\t\tif err != nil {\n\t\t\tif err != io.EOF {\n\t\t\t\tch <- StreamChunk{Done: true, Error: err}\n\t\t\t} else {\n\t\t\t\tfinish(false)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\tline = strings.TrimSpace(line)\n\t\tif line == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tif !strings.HasPrefix(line, \"data: \") {\n\t\t\tcontinue\n\t\t}\n\n\t\tdata := strings.TrimPrefix(line, \"data: \")\n\t\tif data == \"[DONE]\" {\n\t\t\tfinish(true)\n\t\t\treturn\n\t\t}\n\n\t\tvar streamResp openAIResponse\n\t\tif err := json.Unmarshal([]byte(data), &streamResp); err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tif len(streamResp.Choices) > 0 {\n\t\t\tdelta := streamResp.Choices[0].Delta\n\t\t\tchunk := StreamChunk{\n\t\t\t\tContent: delta.Content,\n\t\t\t\tReasoning: delta.ReasoningContent,\n\t\t\t\tFinishReason: streamResp.Choices[0].FinishReason,\n\t\t\t}\n\n\t\t\t// tool_call 分片只进聚合器,不随增量 chunk 下发;流收尾时整块抛出\n\t\t\tfor _, tc := range delta.ToolCalls {\n\t\t\t\ttoolCalls.add(tc)\n\t\t\t}\n\n\t\t\tif chunk.Content != \"\" || chunk.Reasoning != \"\" || chunk.FinishReason != \"\" {\n\t\t\t\tch <- chunk\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (o *OpenAI) convertRequest(req *GenerateRequest) *openAIRequest {\n\topenAIReq := &openAIRequest{\n\t\tModel: o.config.Model,\n\t\tTopP: req.TopP,\n\t\textraBody: o.config.ExtraBody,\n\t}\n\n\t// Temperature: request 优先,fallback 到 config 默认值\n\tswitch {\n\tcase req.Temperature != nil:\n\t\topenAIReq.Temperature = *req.Temperature\n\tcase o.config.Temperature != nil:\n\t\topenAIReq.Temperature = *o.config.Temperature\n\t}\n\n\t// MaxTokens: 同上\n\tswitch {\n\tcase req.MaxTokens != nil:\n\t\topenAIReq.MaxTokens = *req.MaxTokens\n\tcase o.config.MaxTokens != nil:\n\t\topenAIReq.MaxTokens = *o.config.MaxTokens\n\t}\n\n\t// Convert messages. 结构化工具轮:assistant 的 ToolCalls 与 RoleTool 结果轮\n\t// 直接映射到 OpenAI 原生 tool_calls/tool 角色。\n\tfor _, msg := range req.Messages {\n\t\toaMsg := openAIMessage{\n\t\t\tRole: msg.Role,\n\t\t\tContent: msg.Content,\n\t\t\tToolCallID: msg.ToolCallID,\n\t\t}\n\t\t// tool 结果轮 content 必填:工具返回空串时,omitempty 会把 content 字段\n\t\t// 整个丢掉(对 assistant tool-call 轮是对的),严格端点直接 400 拒绝整单。\n\t\tif msg.Role == RoleTool && oaMsg.Content == \"\" {\n\t\t\toaMsg.Content = \"(empty result)\"\n\t\t}\n\t\tfor _, tc := range msg.ToolCalls {\n\t\t\toaTC := openAIToolCall{ID: tc.ID, Type: \"function\"}\n\t\t\toaTC.Function.Name = tc.Name\n\t\t\toaTC.Function.Arguments = tc.Arguments\n\t\t\toaMsg.ToolCalls = append(oaMsg.ToolCalls, oaTC)\n\t\t}\n\t\topenAIReq.Messages = append(openAIReq.Messages, oaMsg)\n\t}\n\n\t// Convert tools\n\tfor _, tool := range req.Tools {\n\t\topenAIReq.Tools = append(openAIReq.Tools, openAITool{\n\t\t\tType: \"function\",\n\t\t\tFunction: openAIFunction{\n\t\t\t\tName: tool.Name,\n\t\t\t\tDescription: tool.Description,\n\t\t\t\tParameters: tool.Parameters,\n\t\t\t},\n\t\t})\n\t}\n\n\treturn openAIReq\n}\n\nfunc (o *OpenAI) convertResponse(resp *openAIResponse) *GenerateResponse {\n\tresult := &GenerateResponse{}\n\n\tif len(resp.Choices) > 0 {\n\t\tchoice := resp.Choices[0]\n\t\tresult.Content = choice.Message.Content\n\t\tresult.ReasoningContent = choice.Message.ReasoningContent\n\t\tresult.FinishReason = choice.FinishReason\n\n\t\t// Convert tool calls\n\t\tfor _, tc := range choice.Message.ToolCalls {\n\t\t\tresult.ToolCalls = append(result.ToolCalls, ToolCall{\n\t\t\t\tID: tc.ID,\n\t\t\t\tName: tc.Function.Name,\n\t\t\t\tArguments: tc.Function.Arguments,\n\t\t\t})\n\t\t}\n\t}\n\n\tif resp.Usage != nil {\n\t\tresult.Usage = &Usage{\n\t\t\tPromptTokens: resp.Usage.PromptTokens,\n\t\t\tCompletionTokens: resp.Usage.CompletionTokens,\n\t\t\tTotalTokens: resp.Usage.TotalTokens,\n\t\t}\n\t}\n\n\treturn result\n}\n\nfunc (o *OpenAI) doRequest(ctx context.Context, req *openAIRequest) ([]byte, error) {\n\tjsonData, err := json.Marshal(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to marshal request: %w\", err)\n\t}\n\treturn doHTTPWithRetry(ctx, o.client, \"OpenAI\",\n\t\tfunc() (*http.Request, error) {\n\t\t\treturn http.NewRequestWithContext(ctx, \"POST\", o.config.BaseURL, bytes.NewBuffer(jsonData))\n\t\t},\n\t\to.setHeaders,\n\t)\n}\n\nfunc (o *OpenAI) setHeaders(req *http.Request) {\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\tif o.config.APIKey != \"\" {\n\t\treq.Header.Set(\"Authorization\", \"Bearer \"+o.config.APIKey)\n\t}\n\n\tApplyCustomHeaders(req, o.config.Headers)\n}\n"}, "516deadbc88713af184b91be46e3422673a5c720": {"t": 1785759232.8181815, "n": 12841, "h": "792f713dacc8158d", "c": "package llm\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/toolkits/pkg/logger\"\n)\n\nconst DefaultOpenAIURL = \"https://api.openai.com/v1/chat/completions\"\n\n// maxRetries / initialRetryWait / maxRetryWait 迁移至 defaults.go\n\n// OpenAI implements the LLM interface for OpenAI and compatible APIs\ntype OpenAI struct {\n\tconfig *Config\n\tclient *http.Client\n}\n\n// NewOpenAI creates a new OpenAI provider\nfunc NewOpenAI(cfg *Config, client *http.Client) (*OpenAI, error) {\n\tif cfg.BaseURL == \"\" {\n\t\tcfg.BaseURL = DefaultOpenAIURL\n\t} else {\n\t\tcfg.BaseURL = NormalizeOpenAIURL(cfg.BaseURL)\n\t}\n\treturn &OpenAI{\n\t\tconfig: cfg,\n\t\tclient: client,\n\t}, nil\n}\n\n// NormalizeOpenAIURL 归一化 OpenAI 兼容端点:\n// 用户常见填法包括 `https://host/v1` 或 `https://host/compatible-mode/v1`,\n// 这里自动补 `/chat/completions`,避免漏路径时返回 404。\n// 对于已知需要 /v1/chat/completions 路径的提供商(如 DeepSeek),\n// 当用户只填写了根域名时自动补全完整路径。\nfunc NormalizeOpenAIURL(rawURL string) string {\n\tu := strings.TrimRight(rawURL, \"/\")\n\tif strings.HasSuffix(u, \"/chat/completions\") {\n\t\treturn u\n\t}\n\tif strings.HasSuffix(u, \"/v1\") {\n\t\treturn u + \"/chat/completions\"\n\t}\n\t// DeepSeek 使用标准 OpenAI 兼容路径,用户填写根域名时自动补全\n\tif u == \"https://api.deepseek.com\" || u == \"http://api.deepseek.com\" {\n\t\treturn u + \"/v1/chat/completions\"\n\t}\n\treturn u\n}\n\nfunc (o *OpenAI) Name() string {\n\treturn ProviderOpenAI\n}\n\n// OpenAI API request/response structures\ntype openAIRequest struct {\n\tModel string `json:\"model\"`\n\tMessages []openAIMessage `json:\"messages\"`\n\tTools []openAITool `json:\"tools,omitempty\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n\tTemperature float64 `json:\"temperature,omitempty\"`\n\tTopP float64 `json:\"top_p,omitempty\"`\n\tStream bool `json:\"stream,omitempty\"`\n\n\t// extraBody 不出现在 JSON tag 里——由 MarshalJSON 平铺到顶层。\n\t// 用于把 LLMConfig.ExtraBody 透传给厂商特定字段(如 dashscope 的 enable_thinking)。\n\textraBody map[string]any\n}\n\n// MarshalJSON 把 extraBody 平铺到顶层 JSON。逻辑:先用别名走默认序列化(拿到所有\n// 显式字段),unmarshal 回 map,再合并 extraBody,最后 marshal 出去。这种\"先反射\n// 再合并\"的写法多绕一步,但好处是新增显式字段时不用同步改任何 reflection 代码。\n// 已存在的 key 由显式字段优先(不让 extraBody 偷偷覆盖 model/messages 等关键字段)。\nfunc (r openAIRequest) MarshalJSON() ([]byte, error) {\n\ttype alias openAIRequest\n\tdata, err := json.Marshal(alias(r))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif len(r.extraBody) == 0 {\n\t\treturn data, nil\n\t}\n\tvar m map[string]any\n\tif err := json.Unmarshal(data, &m); err != nil {\n\t\treturn nil, err\n\t}\n\tfor k, v := range r.extraBody {\n\t\tif _, occupied := m[k]; occupied {\n\t\t\tcontinue\n\t\t}\n\t\tm[k] = v\n\t}\n\treturn json.Marshal(m)\n}\n\ntype openAIMessage struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content,omitempty\"`\n\tReasoningContent string `json:\"reasoning_content,omitempty\"`\n\tToolCalls []openAIToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string `json:\"tool_call_id,omitempty\"`\n}\n\ntype openAITool struct {\n\tType string `json:\"type\"`\n\tFunction openAIFunction `json:\"function\"`\n}\n\ntype openAIFunction struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tParameters map[string]interface{} `json:\"parameters,omitempty\"`\n}\n\ntype openAIToolCall struct {\n\t// Index 仅流式 delta 携带:同轮多个并行 tool_call 靠它区分归属。\n\t// 指针以区分\"未携带\"(部分网关)与合法的 index=0;请求侧序列化时省略。\n\tIndex *int `json:\"index,omitempty\"`\n\tID string `json:\"id\"`\n\tType string `json:\"type\"`\n\tFunction struct {\n\t\tName string `json:\"name\"`\n\t\tArguments string `json:\"arguments\"`\n\t} `json:\"function\"`\n}\n\ntype openAIResponse struct {\n\tID string `json:\"id\"`\n\tObject string `json:\"object\"`\n\tCreated int64 `json:\"created\"`\n\tModel string `json:\"model\"`\n\tChoices []struct {\n\t\tIndex int `json:\"index\"`\n\t\tMessage openAIMessage `json:\"message\"`\n\t\tDelta openAIMessage `json:\"delta\"`\n\t\tFinishReason string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tUsage *struct {\n\t\tPromptTokens int `json:\"prompt_tokens\"`\n\t\tCompletionTokens int `json:\"completion_tokens\"`\n\t\tTotalTokens int `json:\"total_tokens\"`\n\t} `json:\"usage,omitempty\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t\tType string `json:\"type\"`\n\t\tCode string `json:\"code\"`\n\t} `json:\"error,omitempty\"`\n}\n\nfunc (o *OpenAI) Generate(ctx context.Context, req *GenerateRequest) (*GenerateResponse, error) {\n\t// Convert to OpenAI format\n\topenAIReq := o.convertRequest(req)\n\topenAIReq.Stream = false\n\n\t// Make request\n\trespBody, err := o.doRequest(ctx, openAIReq)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Parse response\n\tvar openAIResp openAIResponse\n\tif err := json.Unmarshal(respBody, &openAIResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse response: %w\", err)\n\t}\n\n\tif openAIResp.Error != nil {\n\t\treturn nil, fmt.Errorf(\"OpenAI API error: %s\", openAIResp.Error.Message)\n\t}\n\n\tif len(openAIResp.Choices) == 0 {\n\t\treturn nil, fmt.Errorf(\"no response from OpenAI\")\n\t}\n\n\t// Convert to unified response\n\treturn o.convertResponse(&openAIResp), nil\n}\n\nfunc (o *OpenAI) GenerateStream(ctx context.Context, req *GenerateRequest) (<-chan StreamChunk, error) {\n\topenAIReq := o.convertRequest(req)\n\topenAIReq.Stream = true\n\n\tjsonData, err := json.Marshal(openAIReq)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to marshal request: %w\", err)\n\t}\n\t// Debug 日志:确认 extraBody 等扩展字段是否真的被序列化进请求体。\n\t// 只在 ExtraBody 非空时打印,避免常态噪音;只打前 512 字节防止泄漏长 prompt。\n\tif len(o.config.ExtraBody) > 0 {\n\t\tpreview := string(jsonData)\n\t\tif len(preview) > 512 {\n\t\t\tpreview = preview[:512] + \"...(truncated)\"\n\t\t}\n\t\tlogger.Debugf(\"[OpenAI] stream request body (with extra): %s\", preview)\n\t}\n\n\tresp, err := doHTTPStreamWithRetry(ctx, o.client, \"OpenAI\",\n\t\tfunc() (*http.Request, error) {\n\t\t\treturn http.NewRequestWithContext(ctx, \"POST\", o.config.BaseURL, bytes.NewBuffer(jsonData))\n\t\t},\n\t\to.setHeaders,\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tch := make(chan StreamChunk, 100)\n\tgo o.streamResponse(ctx, resp, ch)\n\treturn ch, nil\n}\n\n// openAIToolCallAggregator 按 index 聚合流式下发的 tool_call 分片。\n// OpenAI 流式协议里同轮多个并行调用靠 index 字段区分:规范只保证每个调用首块带\n// id+name,后续块只带 index+arguments 片段,且不同调用的片段可以交错下发;部分\n// 兼容网关(qwen/deepseek 等)还会在每个 delta 重发 id+name。这里统一按 index\n// 归槽累积,流结束时整块抛出(与 claude.go content_block_stop 的做法对齐),\n// 消费端无需感知分片细节。\ntype openAIToolCallAggregator struct {\n\tslots map[int]*ToolCall\n\torder []int // 首见顺序,flush 时按它输出,保持调用顺序稳定\n\tlast int // 最近分片归入的 index:兜底不带 index 的网关(\"纯参数片段 → 续接上一个\")\n}\n\nfunc (a *openAIToolCallAggregator) add(tc openAIToolCall) {\n\tvar idx int\n\tswitch {\n\tcase tc.Index != nil:\n\t\tidx = *tc.Index\n\tcase tc.ID == \"\" && tc.Function.Name == \"\" && len(a.order) > 0:\n\t\t// 无 index 的纯参数片段:续接最近一个调用(旧启发式,仅作兜底)\n\t\tidx = a.last\n\tdefault:\n\t\t// 无 index 但带 id/name → 新调用,合成一个不与已有槽冲突的 index\n\t\tidx = -1\n\t\tfor _, used := range a.order {\n\t\t\tif used >= idx {\n\t\t\t\tidx = used + 1\n\t\t\t}\n\t\t}\n\t\tif idx < 0 {\n\t\t\tidx = 0\n\t\t}\n\t}\n\n\tslot, ok := a.slots[idx]\n\tif !ok {\n\t\tif a.slots == nil {\n\t\t\ta.slots = map[int]*ToolCall{}\n\t\t}\n\t\tslot = &ToolCall{}\n\t\ta.slots[idx] = slot\n\t\ta.order = append(a.order, idx)\n\t}\n\t// id/name 只取首个非空值:网关重发的 id+name 不会再被误判成新调用\n\tif slot.ID == \"\" {\n\t\tslot.ID = tc.ID\n\t}\n\tif slot.Name == \"\" {\n\t\tslot.Name = tc.Function.Name\n\t}\n\tslot.Arguments += tc.Function.Arguments\n\ta.last = idx\n}\n\n// flush 按首见顺序吐出聚合完成的调用并清空状态(幂等,重复调用返回 nil)。\nfunc (a *openAIToolCallAggregator) flush() []ToolCall {\n\tif len(a.order) == 0 {\n\t\treturn nil\n\t}\n\tcalls := make([]ToolCall, 0, len(a.order))\n\tfor _, idx := range a.order {\n\t\tcalls = append(calls, *a.slots[idx])\n\t}\n\ta.slots = nil\n\ta.order = nil\n\treturn calls\n}\n\nfunc (o *OpenAI) streamResponse(ctx context.Context, resp *http.Response, ch chan<- StreamChunk) {\n\tdefer close(ch)\n\tdefer resp.Body.Close()\n\n\treader := bufio.NewReader(resp.Body)\n\tvar toolCalls openAIToolCallAggregator\n\n\t// finish 在流正常收尾时把聚合好的完整 tool_call 整块抛出后再发 Done;\n\t// 出错路径不 flush——参数片段不完整,吐出去只会变成损坏的 JSON 调用。\n\t// complete 区分两种收尾:[DONE] 表示协议走完,调用原样下发(模型自产的坏\n\t// JSON 也该进工具循环,靠错误观测喂回模型重试);EOF 兜底(网关没发 [DONE]\n\t// 就断流,如 idle timeout 干净 FIN)下连接可能停在 arguments 片段中途,\n\t// JSON 不完整的调用丢弃并告警——下游 unmarshal 失败会把残参包成\n\t// {\"input\": raw} 真执行,比丢调用更危险。空 arguments 视为完整(无参调用)。\n\tfinish := func(complete bool) {\n\t\tcalls := toolCalls.flush()\n\t\tif !complete {\n\t\t\tintact := calls[:0]\n\t\t\tfor _, call := range calls {\n\t\t\t\tif call.Arguments == \"\" || json.Valid([]byte(call.Arguments)) {\n\t\t\t\t\tintact = append(intact, call)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tlogger.Warningf(\"[OpenAI] stream ended without [DONE], drop tool call with truncated arguments: name=%s id=%s args_len=%d\", call.Name, call.ID, len(call.Arguments))\n\t\t\t}\n\t\t\tcalls = intact\n\t\t}\n\t\tif len(calls) > 0 {\n\t\t\tch <- StreamChunk{ToolCalls: calls}\n\t\t}\n\t\tch <- StreamChunk{Done: true}\n\t}\n\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\tch <- StreamChunk{Done: true, Error: ctx.Err()}\n\t\t\treturn\n\t\tdefault:\n\t\t}\n\n\t\tline, err := reader.ReadString('\\n')\n\t\tif err != nil {\n\t\t\tif err != io.EOF {\n\t\t\t\tch <- StreamChunk{Done: true, Error: err}\n\t\t\t} else {\n\t\t\t\tfinish(false)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\tline = strings.TrimSpace(line)\n\t\tif line == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tif !strings.HasPrefix(line, \"data: \") {\n\t\t\tcontinue\n\t\t}\n\n\t\tdata := strings.TrimPrefix(line, \"data: \")\n\t\tif data == \"[DONE]\" {\n\t\t\tfinish(true)\n\t\t\treturn\n\t\t}\n\n\t\tvar streamResp openAIResponse\n\t\tif err := json.Unmarshal([]byte(data), &streamResp); err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tif len(streamResp.Choices) > 0 {\n\t\t\tdelta := streamResp.Choices[0].Delta\n\t\t\tchunk := StreamChunk{\n\t\t\t\tContent: delta.Content,\n\t\t\t\tReasoning: delta.ReasoningContent,\n\t\t\t\tFinishReason: streamResp.Choices[0].FinishReason,\n\t\t\t}\n\n\t\t\t// tool_call 分片只进聚合器,不随增量 chunk 下发;流收尾时整块抛出\n\t\t\tfor _, tc := range delta.ToolCalls {\n\t\t\t\ttoolCalls.add(tc)\n\t\t\t}\n\n\t\t\tif chunk.Content != \"\" || chunk.Reasoning != \"\" || chunk.FinishReason != \"\" {\n\t\t\t\tch <- chunk\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (o *OpenAI) convertRequest(req *GenerateRequest) *openAIRequest {\n\topenAIReq := &openAIRequest{\n\t\tModel: o.config.Model,\n\t\tTopP: req.TopP,\n\t\textraBody: o.config.ExtraBody,\n\t}\n\n\t// Temperature: request 优先,fallback 到 config 默认值\n\tswitch {\n\tcase req.Temperature != nil:\n\t\topenAIReq.Temperature = *req.Temperature\n\tcase o.config.Temperature != nil:\n\t\topenAIReq.Temperature = *o.config.Temperature\n\t}\n\n\t// MaxTokens: 同上\n\tswitch {\n\tcase req.MaxTokens != nil:\n\t\topenAIReq.MaxTokens = *req.MaxTokens\n\tcase o.config.MaxTokens != nil:\n\t\topenAIReq.MaxTokens = *o.config.MaxTokens\n\t}\n\n\t// Convert messages. 结构化工具轮:assistant 的 ToolCalls 与 RoleTool 结果轮\n\t// 直接映射到 OpenAI 原生 tool_calls/tool 角色。\n\tfor _, msg := range req.Messages {\n\t\toaMsg := openAIMessage{\n\t\t\tRole: msg.Role,\n\t\t\tContent: msg.Content,\n\t\t\tToolCallID: msg.ToolCallID,\n\t\t}\n\t\t// tool 结果轮 content 必填:工具返回空串时,omitempty 会把 content 字段\n\t\t// 整个丢掉(对 assistant tool-call 轮是对的),严格端点直接 400 拒绝整单。\n\t\tif msg.Role == RoleTool && oaMsg.Content == \"\" {\n\t\t\toaMsg.Content = \"(empty result)\"\n\t\t}\n\t\tfor _, tc := range msg.ToolCalls {\n\t\t\toaTC := openAIToolCall{ID: tc.ID, Type: \"function\"}\n\t\t\toaTC.Function.Name = tc.Name\n\t\t\toaTC.Function.Arguments = tc.Arguments\n\t\t\toaMsg.ToolCalls = append(oaMsg.ToolCalls, oaTC)\n\t\t}\n\t\topenAIReq.Messages = append(openAIReq.Messages, oaMsg)\n\t}\n\n\t// Convert tools\n\tfor _, tool := range req.Tools {\n\t\topenAIReq.Tools = append(openAIReq.Tools, openAITool{\n\t\t\tType: \"function\",\n\t\t\tFunction: openAIFunction{\n\t\t\t\tName: tool.Name,\n\t\t\t\tDescription: tool.Description,\n\t\t\t\tParameters: tool.Parameters,\n\t\t\t},\n\t\t})\n\t}\n\n\treturn openAIReq\n}\n\nfunc (o *OpenAI) convertResponse(resp *openAIResponse) *GenerateResponse {\n\tresult := &GenerateResponse{}\n\n\tif len(resp.Choices) > 0 {\n\t\tchoice := resp.Choices[0]\n\t\tresult.Content = choice.Message.Content\n\t\tresult.ReasoningContent = choice.Message.ReasoningContent\n\t\tresult.FinishReason = choice.FinishReason\n\n\t\t// Convert tool calls\n\t\tfor _, tc := range choice.Message.ToolCalls {\n\t\t\tresult.ToolCalls = append(result.ToolCalls, ToolCall{\n\t\t\t\tID: tc.ID,\n\t\t\t\tName: tc.Function.Name,\n\t\t\t\tArguments: tc.Function.Arguments,\n\t\t\t})\n\t\t}\n\t}\n\n\tif resp.Usage != nil {\n\t\tresult.Usage = &Usage{\n\t\t\tPromptTokens: resp.Usage.PromptTokens,\n\t\t\tCompletionTokens: resp.Usage.CompletionTokens,\n\t\t\tTotalTokens: resp.Usage.TotalTokens,\n\t\t}\n\t}\n\n\treturn result\n}\n\nfunc (o *OpenAI) doRequest(ctx context.Context, req *openAIRequest) ([]byte, error) {\n\tjsonData, err := json.Marshal(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to marshal request: %w\", err)\n\t}\n\treturn doHTTPWithRetry(ctx, o.client, \"OpenAI\",\n\t\tfunc() (*http.Request, error) {\n\t\t\treturn http.NewRequestWithContext(ctx, \"POST\", o.config.BaseURL, bytes.NewBuffer(jsonData))\n\t\t},\n\t\to.setHeaders,\n\t)\n}\n\nfunc (o *OpenAI) setHeaders(req *http.Request) {\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\tif o.config.APIKey != \"\" {\n\t\treq.Header.Set(\"Authorization\", \"Bearer \"+o.config.APIKey)\n\t}\n\n\tApplyCustomHeaders(req, o.config.Headers)\n}\n"}, "ca20377b4447ba222b39deb26918b631792d2373": {"t": 1785759234.337472, "n": 9783, "h": "0c90b8b83c28320f", "c": "package llm\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/toolkits/pkg/logger\"\n)\n\nconst DefaultOpenAIURL = \"https://api.openai.com/v1/chat/completions\"\n\n// maxRetries / initialRetryWait / maxRetryWait 迁移至 defaults.go\n\n// OpenAI implements the LLM interface for OpenAI and compatible APIs\ntype OpenAI struct {\n\tconfig *Config\n\tclient *http.Client\n}\n\n// NewOpenAI creates a new OpenAI provider\nfunc NewOpenAI(cfg *Config, client *http.Client) (*OpenAI, error) {\n\tif cfg.BaseURL == \"\" {\n\t\tcfg.BaseURL = DefaultOpenAIURL\n\t} else {\n\t\tcfg.BaseURL = NormalizeOpenAIURL(cfg.BaseURL)\n\t}\n\treturn &OpenAI{\n\t\tconfig: cfg,\n\t\tclient: client,\n\t}, nil\n}\n\n// NormalizeOpenAIURL 归一化 OpenAI 兼容端点:\n// 用户常见填法包括 `https://host/v1` 或 `https://host/compatible-mode/v1`,\n// 这里自动补 `/chat/completions`,避免漏路径时返回 404。\n// 对于已知需要 /v1/chat/completions 路径的提供商(如 DeepSeek),\n// 当用户只填写了根域名时自动补全完整路径。\nfunc NormalizeOpenAIURL(rawURL string) string {\n\tu := strings.TrimRight(rawURL, \"/\")\n\tif strings.HasSuffix(u, \"/chat/completions\") {\n\t\treturn u\n\t}\n\tif strings.HasSuffix(u, \"/v1\") {\n\t\treturn u + \"/chat/completions\"\n\t}\n\t// DeepSeek 使用标准 OpenAI 兼容路径,用户填写根域名时自动补全\n\tif u == \"https://api.deepseek.com\" || u == \"http://api.deepseek.com\" {\n\t\treturn u + \"/v1/chat/completions\"\n\t}\n\treturn u\n}\n\nfunc (o *OpenAI) Name() string {\n\treturn ProviderOpenAI\n}\n\n// OpenAI API request/response structures\ntype openAIRequest struct {\n\tModel string `json:\"model\"`\n\tMessages []openAIMessage `json:\"messages\"`\n\tTools []openAITool `json:\"tools,omitempty\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n\tTemperature float64 `json:\"temperature,omitempty\"`\n\tTopP float64 `json:\"top_p,omitempty\"`\n\tStop []string `json:\"stop,omitempty\"`\n\tStream bool `json:\"stream,omitempty\"`\n\n\t// extraBody 不出现在 JSON tag 里——由 MarshalJSON 平铺到顶层。\n\t// 用于把 LLMConfig.ExtraBody 透传给厂商特定字段(如 dashscope 的 enable_thinking)。\n\textraBody map[string]any\n}\n\n// MarshalJSON 把 extraBody 平铺到顶层 JSON。逻辑:先用别名走默认序列化(拿到所有\n// 显式字段),unmarshal 回 map,再合并 extraBody,最后 marshal 出去。这种\"先反射\n// 再合并\"的写法多绕一步,但好处是新增显式字段时不用同步改任何 reflection 代码。\n// 已存在的 key 由显式字段优先(不让 extraBody 偷偷覆盖 model/messages 等关键字段)。\nfunc (r openAIRequest) MarshalJSON() ([]byte, error) {\n\ttype alias openAIRequest\n\tdata, err := json.Marshal(alias(r))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif len(r.extraBody) == 0 {\n\t\treturn data, nil\n\t}\n\tvar m map[string]any\n\tif err := json.Unmarshal(data, &m); err != nil {\n\t\treturn nil, err\n\t}\n\tfor k, v := range r.extraBody {\n\t\tif _, occupied := m[k]; occupied {\n\t\t\tcontinue\n\t\t}\n\t\tm[k] = v\n\t}\n\treturn json.Marshal(m)\n}\n\ntype openAIMessage struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content,omitempty\"`\n\tReasoningContent string `json:\"reasoning_content,omitempty\"`\n\tToolCalls []openAIToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string `json:\"tool_call_id,omitempty\"`\n}\n\ntype openAITool struct {\n\tType string `json:\"type\"`\n\tFunction openAIFunction `json:\"function\"`\n}\n\ntype openAIFunction struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tParameters map[string]interface{} `json:\"parameters,omitempty\"`\n}\n\ntype openAIToolCall struct {\n\tID string `json:\"id\"`\n\tType string `json:\"type\"`\n\tFunction struct {\n\t\tName string `json:\"name\"`\n\t\tArguments string `json:\"arguments\"`\n\t} `json:\"function\"`\n}\n\ntype openAIResponse struct {\n\tID string `json:\"id\"`\n\tObject string `json:\"object\"`\n\tCreated int64 `json:\"created\"`\n\tModel string `json:\"model\"`\n\tChoices []struct {\n\t\tIndex int `json:\"index\"`\n\t\tMessage openAIMessage `json:\"message\"`\n\t\tDelta openAIMessage `json:\"delta\"`\n\t\tFinishReason string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tUsage *struct {\n\t\tPromptTokens int `json:\"prompt_tokens\"`\n\t\tCompletionTokens int `json:\"completion_tokens\"`\n\t\tTotalTokens int `json:\"total_tokens\"`\n\t} `json:\"usage,omitempty\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t\tType string `json:\"type\"`\n\t\tCode string `json:\"code\"`\n\t} `json:\"error,omitempty\"`\n}\n\nfunc (o *OpenAI) Generate(ctx context.Context, req *GenerateRequest) (*GenerateResponse, error) {\n\t// Convert to OpenAI format\n\topenAIReq := o.convertRequest(req)\n\topenAIReq.Stream = false\n\n\t// Make request\n\trespBody, err := o.doRequest(ctx, openAIReq)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Parse response\n\tvar openAIResp openAIResponse\n\tif err := json.Unmarshal(respBody, &openAIResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse response: %w\", err)\n\t}\n\n\tif openAIResp.Error != nil {\n\t\treturn nil, fmt.Errorf(\"OpenAI API error: %s\", openAIResp.Error.Message)\n\t}\n\n\tif len(openAIResp.Choices) == 0 {\n\t\treturn nil, fmt.Errorf(\"no response from OpenAI\")\n\t}\n\n\t// Convert to unified response\n\treturn o.convertResponse(&openAIResp), nil\n}\n\nfunc (o *OpenAI) GenerateStream(ctx context.Context, req *GenerateRequest) (<-chan StreamChunk, error) {\n\topenAIReq := o.convertRequest(req)\n\topenAIReq.Stream = true\n\n\tjsonData, err := json.Marshal(openAIReq)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to marshal request: %w\", err)\n\t}\n\t// Debug 日志:确认 extraBody 等扩展字段是否真的被序列化进请求体。\n\t// 只在 ExtraBody 非空时打印,避免常态噪音;只打前 512 字节防止泄漏长 prompt。\n\tif len(o.config.ExtraBody) > 0 {\n\t\tpreview := string(jsonData)\n\t\tif len(preview) > 512 {\n\t\t\tpreview = preview[:512] + \"...(truncated)\"\n\t\t}\n\t\tlogger.Debugf(\"[OpenAI] stream request body (with extra): %s\", preview)\n\t}\n\n\tresp, err := doHTTPStreamWithRetry(ctx, o.client, \"OpenAI\",\n\t\tfunc() (*http.Request, error) {\n\t\t\treturn http.NewRequestWithContext(ctx, \"POST\", o.config.BaseURL, bytes.NewBuffer(jsonData))\n\t\t},\n\t\to.setHeaders,\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tch := make(chan StreamChunk, 100)\n\tgo o.streamResponse(ctx, resp, ch)\n\treturn ch, nil\n}\n\nfunc (o *OpenAI) streamResponse(ctx context.Context, resp *http.Response, ch chan<- StreamChunk) {\n\tdefer close(ch)\n\tdefer resp.Body.Close()\n\n\treader := bufio.NewReader(resp.Body)\n\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\tch <- StreamChunk{Done: true, Error: ctx.Err()}\n\t\t\treturn\n\t\tdefault:\n\t\t}\n\n\t\tline, err := reader.ReadString('\\n')\n\t\tif err != nil {\n\t\t\tif err != io.EOF {\n\t\t\t\tch <- StreamChunk{Done: true, Error: err}\n\t\t\t} else {\n\t\t\t\tch <- StreamChunk{Done: true}\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\tline = strings.TrimSpace(line)\n\t\tif line == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tif !strings.HasPrefix(line, \"data: \") {\n\t\t\tcontinue\n\t\t}\n\n\t\tdata := strings.TrimPrefix(line, \"data: \")\n\t\tif data == \"[DONE]\" {\n\t\t\tch <- StreamChunk{Done: true}\n\t\t\treturn\n\t\t}\n\n\t\tvar streamResp openAIResponse\n\t\tif err := json.Unmarshal([]byte(data), &streamResp); err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tif len(streamResp.Choices) > 0 {\n\t\t\tdelta := streamResp.Choices[0].Delta\n\t\t\tchunk := StreamChunk{\n\t\t\t\tContent: delta.Content,\n\t\t\t\tFinishReason: streamResp.Choices[0].FinishReason,\n\t\t\t}\n\n\t\t\t// Handle tool calls in stream\n\t\t\tif len(delta.ToolCalls) > 0 {\n\t\t\t\tfor _, tc := range delta.ToolCalls {\n\t\t\t\t\tchunk.ToolCalls = append(chunk.ToolCalls, ToolCall{\n\t\t\t\t\t\tID: tc.ID,\n\t\t\t\t\t\tName: tc.Function.Name,\n\t\t\t\t\t\tArguments: tc.Function.Arguments,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tch <- chunk\n\t\t}\n\t}\n}\n\nfunc (o *OpenAI) convertRequest(req *GenerateRequest) *openAIRequest {\n\topenAIReq := &openAIRequest{\n\t\tModel: o.config.Model,\n\t\tTopP: req.TopP,\n\t\tStop: req.Stop,\n\t\textraBody: o.config.ExtraBody,\n\t}\n\n\t// Temperature: request 优先,fallback 到 config 默认值\n\tswitch {\n\tcase req.Temperature != nil:\n\t\topenAIReq.Temperature = *req.Temperature\n\tcase o.config.Temperature != nil:\n\t\topenAIReq.Temperature = *o.config.Temperature\n\t}\n\n\t// MaxTokens: 同上\n\tswitch {\n\tcase req.MaxTokens != nil:\n\t\topenAIReq.MaxTokens = *req.MaxTokens\n\tcase o.config.MaxTokens != nil:\n\t\topenAIReq.MaxTokens = *o.config.MaxTokens\n\t}\n\n\t// Convert messages\n\tfor _, msg := range req.Messages {\n\t\topenAIReq.Messages = append(openAIReq.Messages, openAIMessage{\n\t\t\tRole: msg.Role,\n\t\t\tContent: msg.Content,\n\t\t})\n\t}\n\n\t// Convert tools\n\tfor _, tool := range req.Tools {\n\t\topenAIReq.Tools = append(openAIReq.Tools, openAITool{\n\t\t\tType: \"function\",\n\t\t\tFunction: openAIFunction{\n\t\t\t\tName: tool.Name,\n\t\t\t\tDescription: tool.Description,\n\t\t\t\tParameters: tool.Parameters,\n\t\t\t},\n\t\t})\n\t}\n\n\treturn openAIReq\n}\n\nfunc (o *OpenAI) convertResponse(resp *openAIResponse) *GenerateResponse {\n\tresult := &GenerateResponse{}\n\n\tif len(resp.Choices) > 0 {\n\t\tchoice := resp.Choices[0]\n\t\tresult.Content = choice.Message.Content\n\t\tresult.ReasoningContent = choice.Message.ReasoningContent\n\t\tresult.FinishReason = choice.FinishReason\n\n\t\t// Convert tool calls\n\t\tfor _, tc := range choice.Message.ToolCalls {\n\t\t\tresult.ToolCalls = append(result.ToolCalls, ToolCall{\n\t\t\t\tID: tc.ID,\n\t\t\t\tName: tc.Function.Name,\n\t\t\t\tArguments: tc.Function.Arguments,\n\t\t\t})\n\t\t}\n\t}\n\n\tif resp.Usage != nil {\n\t\tresult.Usage = &Usage{\n\t\t\tPromptTokens: resp.Usage.PromptTokens,\n\t\t\tCompletionTokens: resp.Usage.CompletionTokens,\n\t\t\tTotalTokens: resp.Usage.TotalTokens,\n\t\t}\n\t}\n\n\treturn result\n}\n\nfunc (o *OpenAI) doRequest(ctx context.Context, req *openAIRequest) ([]byte, error) {\n\tjsonData, err := json.Marshal(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to marshal request: %w\", err)\n\t}\n\treturn doHTTPWithRetry(ctx, o.client, \"OpenAI\",\n\t\tfunc() (*http.Request, error) {\n\t\t\treturn http.NewRequestWithContext(ctx, \"POST\", o.config.BaseURL, bytes.NewBuffer(jsonData))\n\t\t},\n\t\to.setHeaders,\n\t)\n}\n\nfunc (o *OpenAI) setHeaders(req *http.Request) {\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\tif o.config.APIKey != \"\" {\n\t\treq.Header.Set(\"Authorization\", \"Bearer \"+o.config.APIKey)\n\t}\n\n\tApplyCustomHeaders(req, o.config.Headers)\n}\n"}, "8562d63fc85a68bca8fec4cd74bdaaa51c6e36c5": {"t": 1785759235.8174949, "n": 9627, "h": "3ac3cd8a76abc5cf", "c": "package llm\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/toolkits/pkg/logger\"\n)\n\nconst DefaultOpenAIURL = \"https://api.openai.com/v1/chat/completions\"\n\n// maxRetries / initialRetryWait / maxRetryWait 迁移至 defaults.go\n\n// OpenAI implements the LLM interface for OpenAI and compatible APIs\ntype OpenAI struct {\n\tconfig *Config\n\tclient *http.Client\n}\n\n// NewOpenAI creates a new OpenAI provider\nfunc NewOpenAI(cfg *Config, client *http.Client) (*OpenAI, error) {\n\tif cfg.BaseURL == \"\" {\n\t\tcfg.BaseURL = DefaultOpenAIURL\n\t} else {\n\t\tcfg.BaseURL = NormalizeOpenAIURL(cfg.BaseURL)\n\t}\n\treturn &OpenAI{\n\t\tconfig: cfg,\n\t\tclient: client,\n\t}, nil\n}\n\n// NormalizeOpenAIURL 归一化 OpenAI 兼容端点:\n// 用户常见填法包括 `https://host/v1` 或 `https://host/compatible-mode/v1`,\n// 这里自动补 `/chat/completions`,避免漏路径时返回 404。\n// 对于已知需要 /v1/chat/completions 路径的提供商(如 DeepSeek),\n// 当用户只填写了根域名时自动补全完整路径。\nfunc NormalizeOpenAIURL(rawURL string) string {\n\tu := strings.TrimRight(rawURL, \"/\")\n\tif strings.HasSuffix(u, \"/chat/completions\") {\n\t\treturn u\n\t}\n\tif strings.HasSuffix(u, \"/v1\") {\n\t\treturn u + \"/chat/completions\"\n\t}\n\t// DeepSeek 使用标准 OpenAI 兼容路径,用户填写根域名时自动补全\n\tif u == \"https://api.deepseek.com\" || u == \"http://api.deepseek.com\" {\n\t\treturn u + \"/v1/chat/completions\"\n\t}\n\treturn u\n}\n\nfunc (o *OpenAI) Name() string {\n\treturn ProviderOpenAI\n}\n\n// OpenAI API request/response structures\ntype openAIRequest struct {\n\tModel string `json:\"model\"`\n\tMessages []openAIMessage `json:\"messages\"`\n\tTools []openAITool `json:\"tools,omitempty\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n\tTemperature float64 `json:\"temperature,omitempty\"`\n\tTopP float64 `json:\"top_p,omitempty\"`\n\tStop []string `json:\"stop,omitempty\"`\n\tStream bool `json:\"stream,omitempty\"`\n\n\t// extraBody 不出现在 JSON tag 里——由 MarshalJSON 平铺到顶层。\n\t// 用于把 LLMConfig.ExtraBody 透传给厂商特定字段(如 dashscope 的 enable_thinking)。\n\textraBody map[string]any\n}\n\n// MarshalJSON 把 extraBody 平铺到顶层 JSON。逻辑:先用别名走默认序列化(拿到所有\n// 显式字段),unmarshal 回 map,再合并 extraBody,最后 marshal 出去。这种\"先反射\n// 再合并\"的写法多绕一步,但好处是新增显式字段时不用同步改任何 reflection 代码。\n// 已存在的 key 由显式字段优先(不让 extraBody 偷偷覆盖 model/messages 等关键字段)。\nfunc (r openAIRequest) MarshalJSON() ([]byte, error) {\n\ttype alias openAIRequest\n\tdata, err := json.Marshal(alias(r))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif len(r.extraBody) == 0 {\n\t\treturn data, nil\n\t}\n\tvar m map[string]any\n\tif err := json.Unmarshal(data, &m); err != nil {\n\t\treturn nil, err\n\t}\n\tfor k, v := range r.extraBody {\n\t\tif _, occupied := m[k]; occupied {\n\t\t\tcontinue\n\t\t}\n\t\tm[k] = v\n\t}\n\treturn json.Marshal(m)\n}\n\ntype openAIMessage struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content,omitempty\"`\n\tToolCalls []openAIToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string `json:\"tool_call_id,omitempty\"`\n}\n\ntype openAITool struct {\n\tType string `json:\"type\"`\n\tFunction openAIFunction `json:\"function\"`\n}\n\ntype openAIFunction struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tParameters map[string]interface{} `json:\"parameters,omitempty\"`\n}\n\ntype openAIToolCall struct {\n\tID string `json:\"id\"`\n\tType string `json:\"type\"`\n\tFunction struct {\n\t\tName string `json:\"name\"`\n\t\tArguments string `json:\"arguments\"`\n\t} `json:\"function\"`\n}\n\ntype openAIResponse struct {\n\tID string `json:\"id\"`\n\tObject string `json:\"object\"`\n\tCreated int64 `json:\"created\"`\n\tModel string `json:\"model\"`\n\tChoices []struct {\n\t\tIndex int `json:\"index\"`\n\t\tMessage openAIMessage `json:\"message\"`\n\t\tDelta openAIMessage `json:\"delta\"`\n\t\tFinishReason string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tUsage *struct {\n\t\tPromptTokens int `json:\"prompt_tokens\"`\n\t\tCompletionTokens int `json:\"completion_tokens\"`\n\t\tTotalTokens int `json:\"total_tokens\"`\n\t} `json:\"usage,omitempty\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t\tType string `json:\"type\"`\n\t\tCode string `json:\"code\"`\n\t} `json:\"error,omitempty\"`\n}\n\nfunc (o *OpenAI) Generate(ctx context.Context, req *GenerateRequest) (*GenerateResponse, error) {\n\t// Convert to OpenAI format\n\topenAIReq := o.convertRequest(req)\n\topenAIReq.Stream = false\n\n\t// Make request\n\trespBody, err := o.doRequest(ctx, openAIReq)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Parse response\n\tvar openAIResp openAIResponse\n\tif err := json.Unmarshal(respBody, &openAIResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse response: %w\", err)\n\t}\n\n\tif openAIResp.Error != nil {\n\t\treturn nil, fmt.Errorf(\"OpenAI API error: %s\", openAIResp.Error.Message)\n\t}\n\n\tif len(openAIResp.Choices) == 0 {\n\t\treturn nil, fmt.Errorf(\"no response from OpenAI\")\n\t}\n\n\t// Convert to unified response\n\treturn o.convertResponse(&openAIResp), nil\n}\n\nfunc (o *OpenAI) GenerateStream(ctx context.Context, req *GenerateRequest) (<-chan StreamChunk, error) {\n\topenAIReq := o.convertRequest(req)\n\topenAIReq.Stream = true\n\n\tjsonData, err := json.Marshal(openAIReq)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to marshal request: %w\", err)\n\t}\n\t// Debug 日志:确认 extraBody 等扩展字段是否真的被序列化进请求体。\n\t// 只在 ExtraBody 非空时打印,避免常态噪音;只打前 512 字节防止泄漏长 prompt。\n\tif len(o.config.ExtraBody) > 0 {\n\t\tpreview := string(jsonData)\n\t\tif len(preview) > 512 {\n\t\t\tpreview = preview[:512] + \"...(truncated)\"\n\t\t}\n\t\tlogger.Debugf(\"[OpenAI] stream request body (with extra): %s\", preview)\n\t}\n\n\tresp, err := doHTTPStreamWithRetry(ctx, o.client, \"OpenAI\",\n\t\tfunc() (*http.Request, error) {\n\t\t\treturn http.NewRequestWithContext(ctx, \"POST\", o.config.BaseURL, bytes.NewBuffer(jsonData))\n\t\t},\n\t\to.setHeaders,\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tch := make(chan StreamChunk, 100)\n\tgo o.streamResponse(ctx, resp, ch)\n\treturn ch, nil\n}\n\nfunc (o *OpenAI) streamResponse(ctx context.Context, resp *http.Response, ch chan<- StreamChunk) {\n\tdefer close(ch)\n\tdefer resp.Body.Close()\n\n\treader := bufio.NewReader(resp.Body)\n\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\tch <- StreamChunk{Done: true, Error: ctx.Err()}\n\t\t\treturn\n\t\tdefault:\n\t\t}\n\n\t\tline, err := reader.ReadString('\\n')\n\t\tif err != nil {\n\t\t\tif err != io.EOF {\n\t\t\t\tch <- StreamChunk{Done: true, Error: err}\n\t\t\t} else {\n\t\t\t\tch <- StreamChunk{Done: true}\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\tline = strings.TrimSpace(line)\n\t\tif line == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tif !strings.HasPrefix(line, \"data: \") {\n\t\t\tcontinue\n\t\t}\n\n\t\tdata := strings.TrimPrefix(line, \"data: \")\n\t\tif data == \"[DONE]\" {\n\t\t\tch <- StreamChunk{Done: true}\n\t\t\treturn\n\t\t}\n\n\t\tvar streamResp openAIResponse\n\t\tif err := json.Unmarshal([]byte(data), &streamResp); err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tif len(streamResp.Choices) > 0 {\n\t\t\tdelta := streamResp.Choices[0].Delta\n\t\t\tchunk := StreamChunk{\n\t\t\t\tContent: delta.Content,\n\t\t\t\tFinishReason: streamResp.Choices[0].FinishReason,\n\t\t\t}\n\n\t\t\t// Handle tool calls in stream\n\t\t\tif len(delta.ToolCalls) > 0 {\n\t\t\t\tfor _, tc := range delta.ToolCalls {\n\t\t\t\t\tchunk.ToolCalls = append(chunk.ToolCalls, ToolCall{\n\t\t\t\t\t\tID: tc.ID,\n\t\t\t\t\t\tName: tc.Function.Name,\n\t\t\t\t\t\tArguments: tc.Function.Arguments,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tch <- chunk\n\t\t}\n\t}\n}\n\nfunc (o *OpenAI) convertRequest(req *GenerateRequest) *openAIRequest {\n\topenAIReq := &openAIRequest{\n\t\tModel: o.config.Model,\n\t\tTopP: req.TopP,\n\t\tStop: req.Stop,\n\t\textraBody: o.config.ExtraBody,\n\t}\n\n\t// Temperature: request 优先,fallback 到 config 默认值\n\tswitch {\n\tcase req.Temperature != nil:\n\t\topenAIReq.Temperature = *req.Temperature\n\tcase o.config.Temperature != nil:\n\t\topenAIReq.Temperature = *o.config.Temperature\n\t}\n\n\t// MaxTokens: 同上\n\tswitch {\n\tcase req.MaxTokens != nil:\n\t\topenAIReq.MaxTokens = *req.MaxTokens\n\tcase o.config.MaxTokens != nil:\n\t\topenAIReq.MaxTokens = *o.config.MaxTokens\n\t}\n\n\t// Convert messages\n\tfor _, msg := range req.Messages {\n\t\topenAIReq.Messages = append(openAIReq.Messages, openAIMessage{\n\t\t\tRole: msg.Role,\n\t\t\tContent: msg.Content,\n\t\t})\n\t}\n\n\t// Convert tools\n\tfor _, tool := range req.Tools {\n\t\topenAIReq.Tools = append(openAIReq.Tools, openAITool{\n\t\t\tType: \"function\",\n\t\t\tFunction: openAIFunction{\n\t\t\t\tName: tool.Name,\n\t\t\t\tDescription: tool.Description,\n\t\t\t\tParameters: tool.Parameters,\n\t\t\t},\n\t\t})\n\t}\n\n\treturn openAIReq\n}\n\nfunc (o *OpenAI) convertResponse(resp *openAIResponse) *GenerateResponse {\n\tresult := &GenerateResponse{}\n\n\tif len(resp.Choices) > 0 {\n\t\tchoice := resp.Choices[0]\n\t\tresult.Content = choice.Message.Content\n\t\tresult.FinishReason = choice.FinishReason\n\n\t\t// Convert tool calls\n\t\tfor _, tc := range choice.Message.ToolCalls {\n\t\t\tresult.ToolCalls = append(result.ToolCalls, ToolCall{\n\t\t\t\tID: tc.ID,\n\t\t\t\tName: tc.Function.Name,\n\t\t\t\tArguments: tc.Function.Arguments,\n\t\t\t})\n\t\t}\n\t}\n\n\tif resp.Usage != nil {\n\t\tresult.Usage = &Usage{\n\t\t\tPromptTokens: resp.Usage.PromptTokens,\n\t\t\tCompletionTokens: resp.Usage.CompletionTokens,\n\t\t\tTotalTokens: resp.Usage.TotalTokens,\n\t\t}\n\t}\n\n\treturn result\n}\n\nfunc (o *OpenAI) doRequest(ctx context.Context, req *openAIRequest) ([]byte, error) {\n\tjsonData, err := json.Marshal(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to marshal request: %w\", err)\n\t}\n\treturn doHTTPWithRetry(ctx, o.client, \"OpenAI\",\n\t\tfunc() (*http.Request, error) {\n\t\t\treturn http.NewRequestWithContext(ctx, \"POST\", o.config.BaseURL, bytes.NewBuffer(jsonData))\n\t\t},\n\t\to.setHeaders,\n\t)\n}\n\nfunc (o *OpenAI) setHeaders(req *http.Request) {\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\tif o.config.APIKey != \"\" {\n\t\treq.Header.Set(\"Authorization\", \"Bearer \"+o.config.APIKey)\n\t}\n\n\tApplyCustomHeaders(req, o.config.Headers)\n}\n"}, "b2e158171d3b6ae0aaa5411fb9f943cd946110a6": {"t": 1785759237.4610064, "n": 9398, "h": "3851df95adbec4a0", "c": "package llm\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/toolkits/pkg/logger\"\n)\n\nconst DefaultOpenAIURL = \"https://api.openai.com/v1/chat/completions\"\n\n// maxRetries / initialRetryWait / maxRetryWait 迁移至 defaults.go\n\n// OpenAI implements the LLM interface for OpenAI and compatible APIs\ntype OpenAI struct {\n\tconfig *Config\n\tclient *http.Client\n}\n\n// NewOpenAI creates a new OpenAI provider\nfunc NewOpenAI(cfg *Config, client *http.Client) (*OpenAI, error) {\n\tif cfg.BaseURL == \"\" {\n\t\tcfg.BaseURL = DefaultOpenAIURL\n\t} else {\n\t\tcfg.BaseURL = normalizeOpenAIURL(cfg.BaseURL)\n\t}\n\treturn &OpenAI{\n\t\tconfig: cfg,\n\t\tclient: client,\n\t}, nil\n}\n\n// normalizeOpenAIURL 归一化 OpenAI 兼容端点:\n// 用户常见填法包括 `https://host/v1` 或 `https://host/compatible-mode/v1`,\n// 这里自动补 `/chat/completions`,避免漏路径时返回 404。\nfunc normalizeOpenAIURL(rawURL string) string {\n\tu := strings.TrimRight(rawURL, \"/\")\n\tif strings.HasSuffix(u, \"/chat/completions\") {\n\t\treturn u\n\t}\n\tif strings.HasSuffix(u, \"/v1\") {\n\t\treturn u + \"/chat/completions\"\n\t}\n\treturn u\n}\n\nfunc (o *OpenAI) Name() string {\n\treturn ProviderOpenAI\n}\n\n// OpenAI API request/response structures\ntype openAIRequest struct {\n\tModel string `json:\"model\"`\n\tMessages []openAIMessage `json:\"messages\"`\n\tTools []openAITool `json:\"tools,omitempty\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n\tTemperature float64 `json:\"temperature,omitempty\"`\n\tTopP float64 `json:\"top_p,omitempty\"`\n\tStop []string `json:\"stop,omitempty\"`\n\tStream bool `json:\"stream,omitempty\"`\n\n\t// extraBody 不出现在 JSON tag 里——由 MarshalJSON 平铺到顶层。\n\t// 用于把 LLMConfig.ExtraBody 透传给厂商特定字段(如 dashscope 的 enable_thinking)。\n\textraBody map[string]any\n}\n\n// MarshalJSON 把 extraBody 平铺到顶层 JSON。逻辑:先用别名走默认序列化(拿到所有\n// 显式字段),unmarshal 回 map,再合并 extraBody,最后 marshal 出去。这种\"先反射\n// 再合并\"的写法多绕一步,但好处是新增显式字段时不用同步改任何 reflection 代码。\n// 已存在的 key 由显式字段优先(不让 extraBody 偷偷覆盖 model/messages 等关键字段)。\nfunc (r openAIRequest) MarshalJSON() ([]byte, error) {\n\ttype alias openAIRequest\n\tdata, err := json.Marshal(alias(r))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif len(r.extraBody) == 0 {\n\t\treturn data, nil\n\t}\n\tvar m map[string]any\n\tif err := json.Unmarshal(data, &m); err != nil {\n\t\treturn nil, err\n\t}\n\tfor k, v := range r.extraBody {\n\t\tif _, occupied := m[k]; occupied {\n\t\t\tcontinue\n\t\t}\n\t\tm[k] = v\n\t}\n\treturn json.Marshal(m)\n}\n\ntype openAIMessage struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content,omitempty\"`\n\tToolCalls []openAIToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string `json:\"tool_call_id,omitempty\"`\n}\n\ntype openAITool struct {\n\tType string `json:\"type\"`\n\tFunction openAIFunction `json:\"function\"`\n}\n\ntype openAIFunction struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tParameters map[string]interface{} `json:\"parameters,omitempty\"`\n}\n\ntype openAIToolCall struct {\n\tID string `json:\"id\"`\n\tType string `json:\"type\"`\n\tFunction struct {\n\t\tName string `json:\"name\"`\n\t\tArguments string `json:\"arguments\"`\n\t} `json:\"function\"`\n}\n\ntype openAIResponse struct {\n\tID string `json:\"id\"`\n\tObject string `json:\"object\"`\n\tCreated int64 `json:\"created\"`\n\tModel string `json:\"model\"`\n\tChoices []struct {\n\t\tIndex int `json:\"index\"`\n\t\tMessage openAIMessage `json:\"message\"`\n\t\tDelta openAIMessage `json:\"delta\"`\n\t\tFinishReason string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tUsage *struct {\n\t\tPromptTokens int `json:\"prompt_tokens\"`\n\t\tCompletionTokens int `json:\"completion_tokens\"`\n\t\tTotalTokens int `json:\"total_tokens\"`\n\t} `json:\"usage,omitempty\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t\tType string `json:\"type\"`\n\t\tCode string `json:\"code\"`\n\t} `json:\"error,omitempty\"`\n}\n\nfunc (o *OpenAI) Generate(ctx context.Context, req *GenerateRequest) (*GenerateResponse, error) {\n\t// Convert to OpenAI format\n\topenAIReq := o.convertRequest(req)\n\topenAIReq.Stream = false\n\n\t// Make request\n\trespBody, err := o.doRequest(ctx, openAIReq)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Parse response\n\tvar openAIResp openAIResponse\n\tif err := json.Unmarshal(respBody, &openAIResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse response: %w\", err)\n\t}\n\n\tif openAIResp.Error != nil {\n\t\treturn nil, fmt.Errorf(\"OpenAI API error: %s\", openAIResp.Error.Message)\n\t}\n\n\tif len(openAIResp.Choices) == 0 {\n\t\treturn nil, fmt.Errorf(\"no response from OpenAI\")\n\t}\n\n\t// Convert to unified response\n\treturn o.convertResponse(&openAIResp), nil\n}\n\nfunc (o *OpenAI) GenerateStream(ctx context.Context, req *GenerateRequest) (<-chan StreamChunk, error) {\n\topenAIReq := o.convertRequest(req)\n\topenAIReq.Stream = true\n\n\tjsonData, err := json.Marshal(openAIReq)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to marshal request: %w\", err)\n\t}\n\t// Debug 日志:确认 extraBody 等扩展字段是否真的被序列化进请求体。\n\t// 只在 ExtraBody 非空时打印,避免常态噪音;只打前 512 字节防止泄漏长 prompt。\n\tif len(o.config.ExtraBody) > 0 {\n\t\tpreview := string(jsonData)\n\t\tif len(preview) > 512 {\n\t\t\tpreview = preview[:512] + \"...(truncated)\"\n\t\t}\n\t\tlogger.Debugf(\"[OpenAI] stream request body (with extra): %s\", preview)\n\t}\n\n\tresp, err := doHTTPStreamWithRetry(ctx, o.client, \"OpenAI\",\n\t\tfunc() (*http.Request, error) {\n\t\t\treturn http.NewRequestWithContext(ctx, \"POST\", o.config.BaseURL, bytes.NewBuffer(jsonData))\n\t\t},\n\t\to.setHeaders,\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tch := make(chan StreamChunk, 100)\n\tgo o.streamResponse(ctx, resp, ch)\n\treturn ch, nil\n}\n\nfunc (o *OpenAI) streamResponse(ctx context.Context, resp *http.Response, ch chan<- StreamChunk) {\n\tdefer close(ch)\n\tdefer resp.Body.Close()\n\n\treader := bufio.NewReader(resp.Body)\n\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\tch <- StreamChunk{Done: true, Error: ctx.Err()}\n\t\t\treturn\n\t\tdefault:\n\t\t}\n\n\t\tline, err := reader.ReadString('\\n')\n\t\tif err != nil {\n\t\t\tif err != io.EOF {\n\t\t\t\tch <- StreamChunk{Done: true, Error: err}\n\t\t\t} else {\n\t\t\t\tch <- StreamChunk{Done: true}\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\tline = strings.TrimSpace(line)\n\t\tif line == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tif !strings.HasPrefix(line, \"data: \") {\n\t\t\tcontinue\n\t\t}\n\n\t\tdata := strings.TrimPrefix(line, \"data: \")\n\t\tif data == \"[DONE]\" {\n\t\t\tch <- StreamChunk{Done: true}\n\t\t\treturn\n\t\t}\n\n\t\tvar streamResp openAIResponse\n\t\tif err := json.Unmarshal([]byte(data), &streamResp); err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tif len(streamResp.Choices) > 0 {\n\t\t\tdelta := streamResp.Choices[0].Delta\n\t\t\tchunk := StreamChunk{\n\t\t\t\tContent: delta.Content,\n\t\t\t\tFinishReason: streamResp.Choices[0].FinishReason,\n\t\t\t}\n\n\t\t\t// Handle tool calls in stream\n\t\t\tif len(delta.ToolCalls) > 0 {\n\t\t\t\tfor _, tc := range delta.ToolCalls {\n\t\t\t\t\tchunk.ToolCalls = append(chunk.ToolCalls, ToolCall{\n\t\t\t\t\t\tID: tc.ID,\n\t\t\t\t\t\tName: tc.Function.Name,\n\t\t\t\t\t\tArguments: tc.Function.Arguments,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tch <- chunk\n\t\t}\n\t}\n}\n\nfunc (o *OpenAI) convertRequest(req *GenerateRequest) *openAIRequest {\n\topenAIReq := &openAIRequest{\n\t\tModel: o.config.Model,\n\t\tTopP: req.TopP,\n\t\tStop: req.Stop,\n\t\textraBody: o.config.ExtraBody,\n\t}\n\n\t// Temperature: request 优先,fallback 到 config 默认值\n\tswitch {\n\tcase req.Temperature != nil:\n\t\topenAIReq.Temperature = *req.Temperature\n\tcase o.config.Temperature != nil:\n\t\topenAIReq.Temperature = *o.config.Temperature\n\t}\n\n\t// MaxTokens: 同上\n\tswitch {\n\tcase req.MaxTokens != nil:\n\t\topenAIReq.MaxTokens = *req.MaxTokens\n\tcase o.config.MaxTokens != nil:\n\t\topenAIReq.MaxTokens = *o.config.MaxTokens\n\t}\n\n\t// Convert messages\n\tfor _, msg := range req.Messages {\n\t\topenAIReq.Messages = append(openAIReq.Messages, openAIMessage{\n\t\t\tRole: msg.Role,\n\t\t\tContent: msg.Content,\n\t\t})\n\t}\n\n\t// Convert tools\n\tfor _, tool := range req.Tools {\n\t\topenAIReq.Tools = append(openAIReq.Tools, openAITool{\n\t\t\tType: \"function\",\n\t\t\tFunction: openAIFunction{\n\t\t\t\tName: tool.Name,\n\t\t\t\tDescription: tool.Description,\n\t\t\t\tParameters: tool.Parameters,\n\t\t\t},\n\t\t})\n\t}\n\n\treturn openAIReq\n}\n\nfunc (o *OpenAI) convertResponse(resp *openAIResponse) *GenerateResponse {\n\tresult := &GenerateResponse{}\n\n\tif len(resp.Choices) > 0 {\n\t\tchoice := resp.Choices[0]\n\t\tresult.Content = choice.Message.Content\n\t\tresult.FinishReason = choice.FinishReason\n\n\t\t// Convert tool calls\n\t\tfor _, tc := range choice.Message.ToolCalls {\n\t\t\tresult.ToolCalls = append(result.ToolCalls, ToolCall{\n\t\t\t\tID: tc.ID,\n\t\t\t\tName: tc.Function.Name,\n\t\t\t\tArguments: tc.Function.Arguments,\n\t\t\t})\n\t\t}\n\t}\n\n\tif resp.Usage != nil {\n\t\tresult.Usage = &Usage{\n\t\t\tPromptTokens: resp.Usage.PromptTokens,\n\t\t\tCompletionTokens: resp.Usage.CompletionTokens,\n\t\t\tTotalTokens: resp.Usage.TotalTokens,\n\t\t}\n\t}\n\n\treturn result\n}\n\nfunc (o *OpenAI) doRequest(ctx context.Context, req *openAIRequest) ([]byte, error) {\n\tjsonData, err := json.Marshal(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to marshal request: %w\", err)\n\t}\n\treturn doHTTPWithRetry(ctx, o.client, \"OpenAI\",\n\t\tfunc() (*http.Request, error) {\n\t\t\treturn http.NewRequestWithContext(ctx, \"POST\", o.config.BaseURL, bytes.NewBuffer(jsonData))\n\t\t},\n\t\to.setHeaders,\n\t)\n}\n\nfunc (o *OpenAI) setHeaders(req *http.Request) {\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\tif o.config.APIKey != \"\" {\n\t\treq.Header.Set(\"Authorization\", \"Bearer \"+o.config.APIKey)\n\t}\n\n\tApplyCustomHeaders(req, o.config.Headers)\n}\n"}, "a55d00bee3e75b5ab3d9e94b4937bfc2793eb9a3": {"t": 1785759238.867678, "n": 9419, "h": "fbf1209c8040bd95", "c": "package llm\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/toolkits/pkg/logger\"\n)\n\nconst DefaultOpenAIURL = \"https://api.openai.com/v1/chat/completions\"\n\n// maxRetries / initialRetryWait / maxRetryWait 迁移至 defaults.go\n\n// OpenAI implements the LLM interface for OpenAI and compatible APIs\ntype OpenAI struct {\n\tconfig *Config\n\tclient *http.Client\n}\n\n// NewOpenAI creates a new OpenAI provider\nfunc NewOpenAI(cfg *Config, client *http.Client) (*OpenAI, error) {\n\tif cfg.BaseURL == \"\" {\n\t\tcfg.BaseURL = DefaultOpenAIURL\n\t} else {\n\t\tcfg.BaseURL = normalizeOpenAIURL(cfg.BaseURL)\n\t}\n\treturn &OpenAI{\n\t\tconfig: cfg,\n\t\tclient: client,\n\t}, nil\n}\n\n// normalizeOpenAIURL 归一化 OpenAI 兼容端点:\n// 用户常见填法包括 `https://host/v1` 或 `https://host/compatible-mode/v1`,\n// 这里自动补 `/chat/completions`,避免漏路径时返回 404。\nfunc normalizeOpenAIURL(rawURL string) string {\n\tu := strings.TrimRight(rawURL, \"/\")\n\tif strings.HasSuffix(u, \"/chat/completions\") {\n\t\treturn u\n\t}\n\tif strings.HasSuffix(u, \"/v1\") {\n\t\treturn u + \"/chat/completions\"\n\t}\n\treturn u\n}\n\nfunc (o *OpenAI) Name() string {\n\treturn ProviderOpenAI\n}\n\n// OpenAI API request/response structures\ntype openAIRequest struct {\n\tModel string `json:\"model\"`\n\tMessages []openAIMessage `json:\"messages\"`\n\tTools []openAITool `json:\"tools,omitempty\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n\tTemperature float64 `json:\"temperature,omitempty\"`\n\tTopP float64 `json:\"top_p,omitempty\"`\n\tStop []string `json:\"stop,omitempty\"`\n\tStream bool `json:\"stream,omitempty\"`\n\n\t// extraBody 不出现在 JSON tag 里——由 MarshalJSON 平铺到顶层。\n\t// 用于把 LLMConfig.ExtraBody 透传给厂商特定字段(如 dashscope 的 enable_thinking)。\n\textraBody map[string]any\n}\n\n// MarshalJSON 把 extraBody 平铺到顶层 JSON。逻辑:先用别名走默认序列化(拿到所有\n// 显式字段),unmarshal 回 map,再合并 extraBody,最后 marshal 出去。这种\"先反射\n// 再合并\"的写法多绕一步,但好处是新增显式字段时不用同步改任何 reflection 代码。\n// 已存在的 key 由显式字段优先(不让 extraBody 偷偷覆盖 model/messages 等关键字段)。\nfunc (r openAIRequest) MarshalJSON() ([]byte, error) {\n\ttype alias openAIRequest\n\tdata, err := json.Marshal(alias(r))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif len(r.extraBody) == 0 {\n\t\treturn data, nil\n\t}\n\tvar m map[string]any\n\tif err := json.Unmarshal(data, &m); err != nil {\n\t\treturn nil, err\n\t}\n\tfor k, v := range r.extraBody {\n\t\tif _, occupied := m[k]; occupied {\n\t\t\tcontinue\n\t\t}\n\t\tm[k] = v\n\t}\n\treturn json.Marshal(m)\n}\n\ntype openAIMessage struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content,omitempty\"`\n\tToolCalls []openAIToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string `json:\"tool_call_id,omitempty\"`\n}\n\ntype openAITool struct {\n\tType string `json:\"type\"`\n\tFunction openAIFunction `json:\"function\"`\n}\n\ntype openAIFunction struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tParameters map[string]interface{} `json:\"parameters,omitempty\"`\n}\n\ntype openAIToolCall struct {\n\tID string `json:\"id\"`\n\tType string `json:\"type\"`\n\tFunction struct {\n\t\tName string `json:\"name\"`\n\t\tArguments string `json:\"arguments\"`\n\t} `json:\"function\"`\n}\n\ntype openAIResponse struct {\n\tID string `json:\"id\"`\n\tObject string `json:\"object\"`\n\tCreated int64 `json:\"created\"`\n\tModel string `json:\"model\"`\n\tChoices []struct {\n\t\tIndex int `json:\"index\"`\n\t\tMessage openAIMessage `json:\"message\"`\n\t\tDelta openAIMessage `json:\"delta\"`\n\t\tFinishReason string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tUsage *struct {\n\t\tPromptTokens int `json:\"prompt_tokens\"`\n\t\tCompletionTokens int `json:\"completion_tokens\"`\n\t\tTotalTokens int `json:\"total_tokens\"`\n\t} `json:\"usage,omitempty\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t\tType string `json:\"type\"`\n\t\tCode string `json:\"code\"`\n\t} `json:\"error,omitempty\"`\n}\n\nfunc (o *OpenAI) Generate(ctx context.Context, req *GenerateRequest) (*GenerateResponse, error) {\n\t// Convert to OpenAI format\n\topenAIReq := o.convertRequest(req)\n\topenAIReq.Stream = false\n\n\t// Make request\n\trespBody, err := o.doRequest(ctx, openAIReq)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Parse response\n\tvar openAIResp openAIResponse\n\tif err := json.Unmarshal(respBody, &openAIResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse response: %w\", err)\n\t}\n\n\tif openAIResp.Error != nil {\n\t\treturn nil, fmt.Errorf(\"OpenAI API error: %s\", openAIResp.Error.Message)\n\t}\n\n\tif len(openAIResp.Choices) == 0 {\n\t\treturn nil, fmt.Errorf(\"no response from OpenAI\")\n\t}\n\n\t// Convert to unified response\n\treturn o.convertResponse(&openAIResp), nil\n}\n\nfunc (o *OpenAI) GenerateStream(ctx context.Context, req *GenerateRequest) (<-chan StreamChunk, error) {\n\topenAIReq := o.convertRequest(req)\n\topenAIReq.Stream = true\n\n\tjsonData, err := json.Marshal(openAIReq)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to marshal request: %w\", err)\n\t}\n\t// Debug 日志:确认 extraBody 等扩展字段是否真的被序列化进请求体。\n\t// 只在 ExtraBody 非空时打印,避免常态噪音;只打前 512 字节防止泄漏长 prompt。\n\tif len(o.config.ExtraBody) > 0 {\n\t\tpreview := string(jsonData)\n\t\tif len(preview) > 512 {\n\t\t\tpreview = preview[:512] + \"...(truncated)\"\n\t\t}\n\t\tlogger.Debugf(\"[OpenAI] stream request body (with extra): %s\", preview)\n\t}\n\n\tresp, err := doHTTPStreamWithRetry(ctx, o.client, \"OpenAI\",\n\t\tfunc() (*http.Request, error) {\n\t\t\treturn http.NewRequestWithContext(ctx, \"POST\", o.config.BaseURL, bytes.NewBuffer(jsonData))\n\t\t},\n\t\to.setHeaders,\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tch := make(chan StreamChunk, 100)\n\tgo o.streamResponse(ctx, resp, ch)\n\treturn ch, nil\n}\n\nfunc (o *OpenAI) streamResponse(ctx context.Context, resp *http.Response, ch chan<- StreamChunk) {\n\tdefer close(ch)\n\tdefer resp.Body.Close()\n\n\treader := bufio.NewReader(resp.Body)\n\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\tch <- StreamChunk{Done: true, Error: ctx.Err()}\n\t\t\treturn\n\t\tdefault:\n\t\t}\n\n\t\tline, err := reader.ReadString('\\n')\n\t\tif err != nil {\n\t\t\tif err != io.EOF {\n\t\t\t\tch <- StreamChunk{Done: true, Error: err}\n\t\t\t} else {\n\t\t\t\tch <- StreamChunk{Done: true}\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\tline = strings.TrimSpace(line)\n\t\tif line == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tif !strings.HasPrefix(line, \"data: \") {\n\t\t\tcontinue\n\t\t}\n\n\t\tdata := strings.TrimPrefix(line, \"data: \")\n\t\tif data == \"[DONE]\" {\n\t\t\tch <- StreamChunk{Done: true}\n\t\t\treturn\n\t\t}\n\n\t\tvar streamResp openAIResponse\n\t\tif err := json.Unmarshal([]byte(data), &streamResp); err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tif len(streamResp.Choices) > 0 {\n\t\t\tdelta := streamResp.Choices[0].Delta\n\t\t\tchunk := StreamChunk{\n\t\t\t\tContent: delta.Content,\n\t\t\t\tFinishReason: streamResp.Choices[0].FinishReason,\n\t\t\t}\n\n\t\t\t// Handle tool calls in stream\n\t\t\tif len(delta.ToolCalls) > 0 {\n\t\t\t\tfor _, tc := range delta.ToolCalls {\n\t\t\t\t\tchunk.ToolCalls = append(chunk.ToolCalls, ToolCall{\n\t\t\t\t\t\tID: tc.ID,\n\t\t\t\t\t\tName: tc.Function.Name,\n\t\t\t\t\t\tArguments: tc.Function.Arguments,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tch <- chunk\n\t\t}\n\t}\n}\n\nfunc (o *OpenAI) convertRequest(req *GenerateRequest) *openAIRequest {\n\topenAIReq := &openAIRequest{\n\t\tModel: o.config.Model,\n\t\tTopP: req.TopP,\n\t\tStop: req.Stop,\n\t\textraBody: o.config.ExtraBody,\n\t}\n\n\t// Temperature: request 优先,fallback 到 config 默认值\n\tswitch {\n\tcase req.Temperature != nil:\n\t\topenAIReq.Temperature = *req.Temperature\n\tcase o.config.Temperature != nil:\n\t\topenAIReq.Temperature = *o.config.Temperature\n\t}\n\n\t// MaxTokens: 同上\n\tswitch {\n\tcase req.MaxTokens != nil:\n\t\topenAIReq.MaxTokens = *req.MaxTokens\n\tcase o.config.MaxTokens != nil:\n\t\topenAIReq.MaxTokens = *o.config.MaxTokens\n\t}\n\n\t// Convert messages\n\tfor _, msg := range req.Messages {\n\t\topenAIReq.Messages = append(openAIReq.Messages, openAIMessage{\n\t\t\tRole: msg.Role,\n\t\t\tContent: msg.Content,\n\t\t})\n\t}\n\n\t// Convert tools\n\tfor _, tool := range req.Tools {\n\t\topenAIReq.Tools = append(openAIReq.Tools, openAITool{\n\t\t\tType: \"function\",\n\t\t\tFunction: openAIFunction{\n\t\t\t\tName: tool.Name,\n\t\t\t\tDescription: tool.Description,\n\t\t\t\tParameters: tool.Parameters,\n\t\t\t},\n\t\t})\n\t}\n\n\treturn openAIReq\n}\n\nfunc (o *OpenAI) convertResponse(resp *openAIResponse) *GenerateResponse {\n\tresult := &GenerateResponse{}\n\n\tif len(resp.Choices) > 0 {\n\t\tchoice := resp.Choices[0]\n\t\tresult.Content = choice.Message.Content\n\t\tresult.FinishReason = choice.FinishReason\n\n\t\t// Convert tool calls\n\t\tfor _, tc := range choice.Message.ToolCalls {\n\t\t\tresult.ToolCalls = append(result.ToolCalls, ToolCall{\n\t\t\t\tID: tc.ID,\n\t\t\t\tName: tc.Function.Name,\n\t\t\t\tArguments: tc.Function.Arguments,\n\t\t\t})\n\t\t}\n\t}\n\n\tif resp.Usage != nil {\n\t\tresult.Usage = &Usage{\n\t\t\tPromptTokens: resp.Usage.PromptTokens,\n\t\t\tCompletionTokens: resp.Usage.CompletionTokens,\n\t\t\tTotalTokens: resp.Usage.TotalTokens,\n\t\t}\n\t}\n\n\treturn result\n}\n\nfunc (o *OpenAI) doRequest(ctx context.Context, req *openAIRequest) ([]byte, error) {\n\tjsonData, err := json.Marshal(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to marshal request: %w\", err)\n\t}\n\treturn doHTTPWithRetry(ctx, o.client, \"OpenAI\",\n\t\tfunc() (*http.Request, error) {\n\t\t\treturn http.NewRequestWithContext(ctx, \"POST\", o.config.BaseURL, bytes.NewBuffer(jsonData))\n\t\t},\n\t\to.setHeaders,\n\t)\n}\n\nfunc (o *OpenAI) setHeaders(req *http.Request) {\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\tif o.config.APIKey != \"\" {\n\t\treq.Header.Set(\"Authorization\", \"Bearer \"+o.config.APIKey)\n\t}\n\n\tfor k, v := range o.config.Headers {\n\t\treq.Header.Set(k, v)\n\t}\n}\n"}, "c63728a360b40cec318bcf34fc4bb3f9d76c59cd": {"t": 1785759240.2901688, "n": 8257, "h": "bc42f054a7c74129", "c": "package llm\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n)\n\nconst DefaultOpenAIURL = \"https://api.openai.com/v1/chat/completions\"\n\n// maxRetries / initialRetryWait / maxRetryWait 迁移至 defaults.go\n\n// OpenAI implements the LLM interface for OpenAI and compatible APIs\ntype OpenAI struct {\n\tconfig *Config\n\tclient *http.Client\n}\n\n// NewOpenAI creates a new OpenAI provider\nfunc NewOpenAI(cfg *Config, client *http.Client) (*OpenAI, error) {\n\tif cfg.BaseURL == \"\" {\n\t\tcfg.BaseURL = DefaultOpenAIURL\n\t} else {\n\t\tcfg.BaseURL = normalizeOpenAIURL(cfg.BaseURL)\n\t}\n\treturn &OpenAI{\n\t\tconfig: cfg,\n\t\tclient: client,\n\t}, nil\n}\n\n// normalizeOpenAIURL 归一化 OpenAI 兼容端点:\n// 用户常见填法包括 `https://host/v1` 或 `https://host/compatible-mode/v1`,\n// 这里自动补 `/chat/completions`,避免漏路径时返回 404。\nfunc normalizeOpenAIURL(rawURL string) string {\n\tu := strings.TrimRight(rawURL, \"/\")\n\tif strings.HasSuffix(u, \"/chat/completions\") {\n\t\treturn u\n\t}\n\tif strings.HasSuffix(u, \"/v1\") {\n\t\treturn u + \"/chat/completions\"\n\t}\n\treturn u\n}\n\nfunc (o *OpenAI) Name() string {\n\treturn ProviderOpenAI\n}\n\n// OpenAI API request/response structures\ntype openAIRequest struct {\n\tModel string `json:\"model\"`\n\tMessages []openAIMessage `json:\"messages\"`\n\tTools []openAITool `json:\"tools,omitempty\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n\tTemperature float64 `json:\"temperature,omitempty\"`\n\tTopP float64 `json:\"top_p,omitempty\"`\n\tStop []string `json:\"stop,omitempty\"`\n\tStream bool `json:\"stream,omitempty\"`\n}\n\ntype openAIMessage struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content,omitempty\"`\n\tToolCalls []openAIToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string `json:\"tool_call_id,omitempty\"`\n}\n\ntype openAITool struct {\n\tType string `json:\"type\"`\n\tFunction openAIFunction `json:\"function\"`\n}\n\ntype openAIFunction struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tParameters map[string]interface{} `json:\"parameters,omitempty\"`\n}\n\ntype openAIToolCall struct {\n\tID string `json:\"id\"`\n\tType string `json:\"type\"`\n\tFunction struct {\n\t\tName string `json:\"name\"`\n\t\tArguments string `json:\"arguments\"`\n\t} `json:\"function\"`\n}\n\ntype openAIResponse struct {\n\tID string `json:\"id\"`\n\tObject string `json:\"object\"`\n\tCreated int64 `json:\"created\"`\n\tModel string `json:\"model\"`\n\tChoices []struct {\n\t\tIndex int `json:\"index\"`\n\t\tMessage openAIMessage `json:\"message\"`\n\t\tDelta openAIMessage `json:\"delta\"`\n\t\tFinishReason string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tUsage *struct {\n\t\tPromptTokens int `json:\"prompt_tokens\"`\n\t\tCompletionTokens int `json:\"completion_tokens\"`\n\t\tTotalTokens int `json:\"total_tokens\"`\n\t} `json:\"usage,omitempty\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t\tType string `json:\"type\"`\n\t\tCode string `json:\"code\"`\n\t} `json:\"error,omitempty\"`\n}\n\nfunc (o *OpenAI) Generate(ctx context.Context, req *GenerateRequest) (*GenerateResponse, error) {\n\t// Convert to OpenAI format\n\topenAIReq := o.convertRequest(req)\n\topenAIReq.Stream = false\n\n\t// Make request\n\trespBody, err := o.doRequest(ctx, openAIReq)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Parse response\n\tvar openAIResp openAIResponse\n\tif err := json.Unmarshal(respBody, &openAIResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse response: %w\", err)\n\t}\n\n\tif openAIResp.Error != nil {\n\t\treturn nil, fmt.Errorf(\"OpenAI API error: %s\", openAIResp.Error.Message)\n\t}\n\n\tif len(openAIResp.Choices) == 0 {\n\t\treturn nil, fmt.Errorf(\"no response from OpenAI\")\n\t}\n\n\t// Convert to unified response\n\treturn o.convertResponse(&openAIResp), nil\n}\n\nfunc (o *OpenAI) GenerateStream(ctx context.Context, req *GenerateRequest) (<-chan StreamChunk, error) {\n\topenAIReq := o.convertRequest(req)\n\topenAIReq.Stream = true\n\n\tjsonData, err := json.Marshal(openAIReq)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to marshal request: %w\", err)\n\t}\n\n\tresp, err := doHTTPStreamWithRetry(ctx, o.client, \"OpenAI\",\n\t\tfunc() (*http.Request, error) {\n\t\t\treturn http.NewRequestWithContext(ctx, \"POST\", o.config.BaseURL, bytes.NewBuffer(jsonData))\n\t\t},\n\t\to.setHeaders,\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tch := make(chan StreamChunk, 100)\n\tgo o.streamResponse(ctx, resp, ch)\n\treturn ch, nil\n}\n\nfunc (o *OpenAI) streamResponse(ctx context.Context, resp *http.Response, ch chan<- StreamChunk) {\n\tdefer close(ch)\n\tdefer resp.Body.Close()\n\n\treader := bufio.NewReader(resp.Body)\n\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\tch <- StreamChunk{Done: true, Error: ctx.Err()}\n\t\t\treturn\n\t\tdefault:\n\t\t}\n\n\t\tline, err := reader.ReadString('\\n')\n\t\tif err != nil {\n\t\t\tif err != io.EOF {\n\t\t\t\tch <- StreamChunk{Done: true, Error: err}\n\t\t\t} else {\n\t\t\t\tch <- StreamChunk{Done: true}\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\tline = strings.TrimSpace(line)\n\t\tif line == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tif !strings.HasPrefix(line, \"data: \") {\n\t\t\tcontinue\n\t\t}\n\n\t\tdata := strings.TrimPrefix(line, \"data: \")\n\t\tif data == \"[DONE]\" {\n\t\t\tch <- StreamChunk{Done: true}\n\t\t\treturn\n\t\t}\n\n\t\tvar streamResp openAIResponse\n\t\tif err := json.Unmarshal([]byte(data), &streamResp); err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tif len(streamResp.Choices) > 0 {\n\t\t\tdelta := streamResp.Choices[0].Delta\n\t\t\tchunk := StreamChunk{\n\t\t\t\tContent: delta.Content,\n\t\t\t\tFinishReason: streamResp.Choices[0].FinishReason,\n\t\t\t}\n\n\t\t\t// Handle tool calls in stream\n\t\t\tif len(delta.ToolCalls) > 0 {\n\t\t\t\tfor _, tc := range delta.ToolCalls {\n\t\t\t\t\tchunk.ToolCalls = append(chunk.ToolCalls, ToolCall{\n\t\t\t\t\t\tID: tc.ID,\n\t\t\t\t\t\tName: tc.Function.Name,\n\t\t\t\t\t\tArguments: tc.Function.Arguments,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tch <- chunk\n\t\t}\n\t}\n}\n\nfunc (o *OpenAI) convertRequest(req *GenerateRequest) *openAIRequest {\n\topenAIReq := &openAIRequest{\n\t\tModel: o.config.Model,\n\t\tTopP: req.TopP,\n\t\tStop: req.Stop,\n\t}\n\n\t// Temperature: request 优先,fallback 到 config 默认值\n\tswitch {\n\tcase req.Temperature != nil:\n\t\topenAIReq.Temperature = *req.Temperature\n\tcase o.config.Temperature != nil:\n\t\topenAIReq.Temperature = *o.config.Temperature\n\t}\n\n\t// MaxTokens: 同上\n\tswitch {\n\tcase req.MaxTokens != nil:\n\t\topenAIReq.MaxTokens = *req.MaxTokens\n\tcase o.config.MaxTokens != nil:\n\t\topenAIReq.MaxTokens = *o.config.MaxTokens\n\t}\n\n\t// Convert messages\n\tfor _, msg := range req.Messages {\n\t\topenAIReq.Messages = append(openAIReq.Messages, openAIMessage{\n\t\t\tRole: msg.Role,\n\t\t\tContent: msg.Content,\n\t\t})\n\t}\n\n\t// Convert tools\n\tfor _, tool := range req.Tools {\n\t\topenAIReq.Tools = append(openAIReq.Tools, openAITool{\n\t\t\tType: \"function\",\n\t\t\tFunction: openAIFunction{\n\t\t\t\tName: tool.Name,\n\t\t\t\tDescription: tool.Description,\n\t\t\t\tParameters: tool.Parameters,\n\t\t\t},\n\t\t})\n\t}\n\n\treturn openAIReq\n}\n\nfunc (o *OpenAI) convertResponse(resp *openAIResponse) *GenerateResponse {\n\tresult := &GenerateResponse{}\n\n\tif len(resp.Choices) > 0 {\n\t\tchoice := resp.Choices[0]\n\t\tresult.Content = choice.Message.Content\n\t\tresult.FinishReason = choice.FinishReason\n\n\t\t// Convert tool calls\n\t\tfor _, tc := range choice.Message.ToolCalls {\n\t\t\tresult.ToolCalls = append(result.ToolCalls, ToolCall{\n\t\t\t\tID: tc.ID,\n\t\t\t\tName: tc.Function.Name,\n\t\t\t\tArguments: tc.Function.Arguments,\n\t\t\t})\n\t\t}\n\t}\n\n\tif resp.Usage != nil {\n\t\tresult.Usage = &Usage{\n\t\t\tPromptTokens: resp.Usage.PromptTokens,\n\t\t\tCompletionTokens: resp.Usage.CompletionTokens,\n\t\t\tTotalTokens: resp.Usage.TotalTokens,\n\t\t}\n\t}\n\n\treturn result\n}\n\nfunc (o *OpenAI) doRequest(ctx context.Context, req *openAIRequest) ([]byte, error) {\n\tjsonData, err := json.Marshal(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to marshal request: %w\", err)\n\t}\n\treturn doHTTPWithRetry(ctx, o.client, \"OpenAI\",\n\t\tfunc() (*http.Request, error) {\n\t\t\treturn http.NewRequestWithContext(ctx, \"POST\", o.config.BaseURL, bytes.NewBuffer(jsonData))\n\t\t},\n\t\to.setHeaders,\n\t)\n}\n\nfunc (o *OpenAI) setHeaders(req *http.Request) {\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\tif o.config.APIKey != \"\" {\n\t\treq.Header.Set(\"Authorization\", \"Bearer \"+o.config.APIKey)\n\t}\n\n\tfor k, v := range o.config.Headers {\n\t\treq.Header.Set(k, v)\n\t}\n}\n"}, "a9e551ad3aedefe9a879c572bba138b2fa7bfcc1": {"t": 1786067566.866341, "n": 12841, "h": "792f713dacc8158d", "c": "package llm\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/toolkits/pkg/logger\"\n)\n\nconst DefaultOpenAIURL = \"https://api.openai.com/v1/chat/completions\"\n\n// maxRetries / initialRetryWait / maxRetryWait 迁移至 defaults.go\n\n// OpenAI implements the LLM interface for OpenAI and compatible APIs\ntype OpenAI struct {\n\tconfig *Config\n\tclient *http.Client\n}\n\n// NewOpenAI creates a new OpenAI provider\nfunc NewOpenAI(cfg *Config, client *http.Client) (*OpenAI, error) {\n\tif cfg.BaseURL == \"\" {\n\t\tcfg.BaseURL = DefaultOpenAIURL\n\t} else {\n\t\tcfg.BaseURL = NormalizeOpenAIURL(cfg.BaseURL)\n\t}\n\treturn &OpenAI{\n\t\tconfig: cfg,\n\t\tclient: client,\n\t}, nil\n}\n\n// NormalizeOpenAIURL 归一化 OpenAI 兼容端点:\n// 用户常见填法包括 `https://host/v1` 或 `https://host/compatible-mode/v1`,\n// 这里自动补 `/chat/completions`,避免漏路径时返回 404。\n// 对于已知需要 /v1/chat/completions 路径的提供商(如 DeepSeek),\n// 当用户只填写了根域名时自动补全完整路径。\nfunc NormalizeOpenAIURL(rawURL string) string {\n\tu := strings.TrimRight(rawURL, \"/\")\n\tif strings.HasSuffix(u, \"/chat/completions\") {\n\t\treturn u\n\t}\n\tif strings.HasSuffix(u, \"/v1\") {\n\t\treturn u + \"/chat/completions\"\n\t}\n\t// DeepSeek 使用标准 OpenAI 兼容路径,用户填写根域名时自动补全\n\tif u == \"https://api.deepseek.com\" || u == \"http://api.deepseek.com\" {\n\t\treturn u + \"/v1/chat/completions\"\n\t}\n\treturn u\n}\n\nfunc (o *OpenAI) Name() string {\n\treturn ProviderOpenAI\n}\n\n// OpenAI API request/response structures\ntype openAIRequest struct {\n\tModel string `json:\"model\"`\n\tMessages []openAIMessage `json:\"messages\"`\n\tTools []openAITool `json:\"tools,omitempty\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n\tTemperature float64 `json:\"temperature,omitempty\"`\n\tTopP float64 `json:\"top_p,omitempty\"`\n\tStream bool `json:\"stream,omitempty\"`\n\n\t// extraBody 不出现在 JSON tag 里——由 MarshalJSON 平铺到顶层。\n\t// 用于把 LLMConfig.ExtraBody 透传给厂商特定字段(如 dashscope 的 enable_thinking)。\n\textraBody map[string]any\n}\n\n// MarshalJSON 把 extraBody 平铺到顶层 JSON。逻辑:先用别名走默认序列化(拿到所有\n// 显式字段),unmarshal 回 map,再合并 extraBody,最后 marshal 出去。这种\"先反射\n// 再合并\"的写法多绕一步,但好处是新增显式字段时不用同步改任何 reflection 代码。\n// 已存在的 key 由显式字段优先(不让 extraBody 偷偷覆盖 model/messages 等关键字段)。\nfunc (r openAIRequest) MarshalJSON() ([]byte, error) {\n\ttype alias openAIRequest\n\tdata, err := json.Marshal(alias(r))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif len(r.extraBody) == 0 {\n\t\treturn data, nil\n\t}\n\tvar m map[string]any\n\tif err := json.Unmarshal(data, &m); err != nil {\n\t\treturn nil, err\n\t}\n\tfor k, v := range r.extraBody {\n\t\tif _, occupied := m[k]; occupied {\n\t\t\tcontinue\n\t\t}\n\t\tm[k] = v\n\t}\n\treturn json.Marshal(m)\n}\n\ntype openAIMessage struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content,omitempty\"`\n\tReasoningContent string `json:\"reasoning_content,omitempty\"`\n\tToolCalls []openAIToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string `json:\"tool_call_id,omitempty\"`\n}\n\ntype openAITool struct {\n\tType string `json:\"type\"`\n\tFunction openAIFunction `json:\"function\"`\n}\n\ntype openAIFunction struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tParameters map[string]interface{} `json:\"parameters,omitempty\"`\n}\n\ntype openAIToolCall struct {\n\t// Index 仅流式 delta 携带:同轮多个并行 tool_call 靠它区分归属。\n\t// 指针以区分\"未携带\"(部分网关)与合法的 index=0;请求侧序列化时省略。\n\tIndex *int `json:\"index,omitempty\"`\n\tID string `json:\"id\"`\n\tType string `json:\"type\"`\n\tFunction struct {\n\t\tName string `json:\"name\"`\n\t\tArguments string `json:\"arguments\"`\n\t} `json:\"function\"`\n}\n\ntype openAIResponse struct {\n\tID string `json:\"id\"`\n\tObject string `json:\"object\"`\n\tCreated int64 `json:\"created\"`\n\tModel string `json:\"model\"`\n\tChoices []struct {\n\t\tIndex int `json:\"index\"`\n\t\tMessage openAIMessage `json:\"message\"`\n\t\tDelta openAIMessage `json:\"delta\"`\n\t\tFinishReason string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tUsage *struct {\n\t\tPromptTokens int `json:\"prompt_tokens\"`\n\t\tCompletionTokens int `json:\"completion_tokens\"`\n\t\tTotalTokens int `json:\"total_tokens\"`\n\t} `json:\"usage,omitempty\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t\tType string `json:\"type\"`\n\t\tCode string `json:\"code\"`\n\t} `json:\"error,omitempty\"`\n}\n\nfunc (o *OpenAI) Generate(ctx context.Context, req *GenerateRequest) (*GenerateResponse, error) {\n\t// Convert to OpenAI format\n\topenAIReq := o.convertRequest(req)\n\topenAIReq.Stream = false\n\n\t// Make request\n\trespBody, err := o.doRequest(ctx, openAIReq)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Parse response\n\tvar openAIResp openAIResponse\n\tif err := json.Unmarshal(respBody, &openAIResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse response: %w\", err)\n\t}\n\n\tif openAIResp.Error != nil {\n\t\treturn nil, fmt.Errorf(\"OpenAI API error: %s\", openAIResp.Error.Message)\n\t}\n\n\tif len(openAIResp.Choices) == 0 {\n\t\treturn nil, fmt.Errorf(\"no response from OpenAI\")\n\t}\n\n\t// Convert to unified response\n\treturn o.convertResponse(&openAIResp), nil\n}\n\nfunc (o *OpenAI) GenerateStream(ctx context.Context, req *GenerateRequest) (<-chan StreamChunk, error) {\n\topenAIReq := o.convertRequest(req)\n\topenAIReq.Stream = true\n\n\tjsonData, err := json.Marshal(openAIReq)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to marshal request: %w\", err)\n\t}\n\t// Debug 日志:确认 extraBody 等扩展字段是否真的被序列化进请求体。\n\t// 只在 ExtraBody 非空时打印,避免常态噪音;只打前 512 字节防止泄漏长 prompt。\n\tif len(o.config.ExtraBody) > 0 {\n\t\tpreview := string(jsonData)\n\t\tif len(preview) > 512 {\n\t\t\tpreview = preview[:512] + \"...(truncated)\"\n\t\t}\n\t\tlogger.Debugf(\"[OpenAI] stream request body (with extra): %s\", preview)\n\t}\n\n\tresp, err := doHTTPStreamWithRetry(ctx, o.client, \"OpenAI\",\n\t\tfunc() (*http.Request, error) {\n\t\t\treturn http.NewRequestWithContext(ctx, \"POST\", o.config.BaseURL, bytes.NewBuffer(jsonData))\n\t\t},\n\t\to.setHeaders,\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tch := make(chan StreamChunk, 100)\n\tgo o.streamResponse(ctx, resp, ch)\n\treturn ch, nil\n}\n\n// openAIToolCallAggregator 按 index 聚合流式下发的 tool_call 分片。\n// OpenAI 流式协议里同轮多个并行调用靠 index 字段区分:规范只保证每个调用首块带\n// id+name,后续块只带 index+arguments 片段,且不同调用的片段可以交错下发;部分\n// 兼容网关(qwen/deepseek 等)还会在每个 delta 重发 id+name。这里统一按 index\n// 归槽累积,流结束时整块抛出(与 claude.go content_block_stop 的做法对齐),\n// 消费端无需感知分片细节。\ntype openAIToolCallAggregator struct {\n\tslots map[int]*ToolCall\n\torder []int // 首见顺序,flush 时按它输出,保持调用顺序稳定\n\tlast int // 最近分片归入的 index:兜底不带 index 的网关(\"纯参数片段 → 续接上一个\")\n}\n\nfunc (a *openAIToolCallAggregator) add(tc openAIToolCall) {\n\tvar idx int\n\tswitch {\n\tcase tc.Index != nil:\n\t\tidx = *tc.Index\n\tcase tc.ID == \"\" && tc.Function.Name == \"\" && len(a.order) > 0:\n\t\t// 无 index 的纯参数片段:续接最近一个调用(旧启发式,仅作兜底)\n\t\tidx = a.last\n\tdefault:\n\t\t// 无 index 但带 id/name → 新调用,合成一个不与已有槽冲突的 index\n\t\tidx = -1\n\t\tfor _, used := range a.order {\n\t\t\tif used >= idx {\n\t\t\t\tidx = used + 1\n\t\t\t}\n\t\t}\n\t\tif idx < 0 {\n\t\t\tidx = 0\n\t\t}\n\t}\n\n\tslot, ok := a.slots[idx]\n\tif !ok {\n\t\tif a.slots == nil {\n\t\t\ta.slots = map[int]*ToolCall{}\n\t\t}\n\t\tslot = &ToolCall{}\n\t\ta.slots[idx] = slot\n\t\ta.order = append(a.order, idx)\n\t}\n\t// id/name 只取首个非空值:网关重发的 id+name 不会再被误判成新调用\n\tif slot.ID == \"\" {\n\t\tslot.ID = tc.ID\n\t}\n\tif slot.Name == \"\" {\n\t\tslot.Name = tc.Function.Name\n\t}\n\tslot.Arguments += tc.Function.Arguments\n\ta.last = idx\n}\n\n// flush 按首见顺序吐出聚合完成的调用并清空状态(幂等,重复调用返回 nil)。\nfunc (a *openAIToolCallAggregator) flush() []ToolCall {\n\tif len(a.order) == 0 {\n\t\treturn nil\n\t}\n\tcalls := make([]ToolCall, 0, len(a.order))\n\tfor _, idx := range a.order {\n\t\tcalls = append(calls, *a.slots[idx])\n\t}\n\ta.slots = nil\n\ta.order = nil\n\treturn calls\n}\n\nfunc (o *OpenAI) streamResponse(ctx context.Context, resp *http.Response, ch chan<- StreamChunk) {\n\tdefer close(ch)\n\tdefer resp.Body.Close()\n\n\treader := bufio.NewReader(resp.Body)\n\tvar toolCalls openAIToolCallAggregator\n\n\t// finish 在流正常收尾时把聚合好的完整 tool_call 整块抛出后再发 Done;\n\t// 出错路径不 flush——参数片段不完整,吐出去只会变成损坏的 JSON 调用。\n\t// complete 区分两种收尾:[DONE] 表示协议走完,调用原样下发(模型自产的坏\n\t// JSON 也该进工具循环,靠错误观测喂回模型重试);EOF 兜底(网关没发 [DONE]\n\t// 就断流,如 idle timeout 干净 FIN)下连接可能停在 arguments 片段中途,\n\t// JSON 不完整的调用丢弃并告警——下游 unmarshal 失败会把残参包成\n\t// {\"input\": raw} 真执行,比丢调用更危险。空 arguments 视为完整(无参调用)。\n\tfinish := func(complete bool) {\n\t\tcalls := toolCalls.flush()\n\t\tif !complete {\n\t\t\tintact := calls[:0]\n\t\t\tfor _, call := range calls {\n\t\t\t\tif call.Arguments == \"\" || json.Valid([]byte(call.Arguments)) {\n\t\t\t\t\tintact = append(intact, call)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tlogger.Warningf(\"[OpenAI] stream ended without [DONE], drop tool call with truncated arguments: name=%s id=%s args_len=%d\", call.Name, call.ID, len(call.Arguments))\n\t\t\t}\n\t\t\tcalls = intact\n\t\t}\n\t\tif len(calls) > 0 {\n\t\t\tch <- StreamChunk{ToolCalls: calls}\n\t\t}\n\t\tch <- StreamChunk{Done: true}\n\t}\n\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\tch <- StreamChunk{Done: true, Error: ctx.Err()}\n\t\t\treturn\n\t\tdefault:\n\t\t}\n\n\t\tline, err := reader.ReadString('\\n')\n\t\tif err != nil {\n\t\t\tif err != io.EOF {\n\t\t\t\tch <- StreamChunk{Done: true, Error: err}\n\t\t\t} else {\n\t\t\t\tfinish(false)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\tline = strings.TrimSpace(line)\n\t\tif line == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tif !strings.HasPrefix(line, \"data: \") {\n\t\t\tcontinue\n\t\t}\n\n\t\tdata := strings.TrimPrefix(line, \"data: \")\n\t\tif data == \"[DONE]\" {\n\t\t\tfinish(true)\n\t\t\treturn\n\t\t}\n\n\t\tvar streamResp openAIResponse\n\t\tif err := json.Unmarshal([]byte(data), &streamResp); err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tif len(streamResp.Choices) > 0 {\n\t\t\tdelta := streamResp.Choices[0].Delta\n\t\t\tchunk := StreamChunk{\n\t\t\t\tContent: delta.Content,\n\t\t\t\tReasoning: delta.ReasoningContent,\n\t\t\t\tFinishReason: streamResp.Choices[0].FinishReason,\n\t\t\t}\n\n\t\t\t// tool_call 分片只进聚合器,不随增量 chunk 下发;流收尾时整块抛出\n\t\t\tfor _, tc := range delta.ToolCalls {\n\t\t\t\ttoolCalls.add(tc)\n\t\t\t}\n\n\t\t\tif chunk.Content != \"\" || chunk.Reasoning != \"\" || chunk.FinishReason != \"\" {\n\t\t\t\tch <- chunk\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (o *OpenAI) convertRequest(req *GenerateRequest) *openAIRequest {\n\topenAIReq := &openAIRequest{\n\t\tModel: o.config.Model,\n\t\tTopP: req.TopP,\n\t\textraBody: o.config.ExtraBody,\n\t}\n\n\t// Temperature: request 优先,fallback 到 config 默认值\n\tswitch {\n\tcase req.Temperature != nil:\n\t\topenAIReq.Temperature = *req.Temperature\n\tcase o.config.Temperature != nil:\n\t\topenAIReq.Temperature = *o.config.Temperature\n\t}\n\n\t// MaxTokens: 同上\n\tswitch {\n\tcase req.MaxTokens != nil:\n\t\topenAIReq.MaxTokens = *req.MaxTokens\n\tcase o.config.MaxTokens != nil:\n\t\topenAIReq.MaxTokens = *o.config.MaxTokens\n\t}\n\n\t// Convert messages. 结构化工具轮:assistant 的 ToolCalls 与 RoleTool 结果轮\n\t// 直接映射到 OpenAI 原生 tool_calls/tool 角色。\n\tfor _, msg := range req.Messages {\n\t\toaMsg := openAIMessage{\n\t\t\tRole: msg.Role,\n\t\t\tContent: msg.Content,\n\t\t\tToolCallID: msg.ToolCallID,\n\t\t}\n\t\t// tool 结果轮 content 必填:工具返回空串时,omitempty 会把 content 字段\n\t\t// 整个丢掉(对 assistant tool-call 轮是对的),严格端点直接 400 拒绝整单。\n\t\tif msg.Role == RoleTool && oaMsg.Content == \"\" {\n\t\t\toaMsg.Content = \"(empty result)\"\n\t\t}\n\t\tfor _, tc := range msg.ToolCalls {\n\t\t\toaTC := openAIToolCall{ID: tc.ID, Type: \"function\"}\n\t\t\toaTC.Function.Name = tc.Name\n\t\t\toaTC.Function.Arguments = tc.Arguments\n\t\t\toaMsg.ToolCalls = append(oaMsg.ToolCalls, oaTC)\n\t\t}\n\t\topenAIReq.Messages = append(openAIReq.Messages, oaMsg)\n\t}\n\n\t// Convert tools\n\tfor _, tool := range req.Tools {\n\t\topenAIReq.Tools = append(openAIReq.Tools, openAITool{\n\t\t\tType: \"function\",\n\t\t\tFunction: openAIFunction{\n\t\t\t\tName: tool.Name,\n\t\t\t\tDescription: tool.Description,\n\t\t\t\tParameters: tool.Parameters,\n\t\t\t},\n\t\t})\n\t}\n\n\treturn openAIReq\n}\n\nfunc (o *OpenAI) convertResponse(resp *openAIResponse) *GenerateResponse {\n\tresult := &GenerateResponse{}\n\n\tif len(resp.Choices) > 0 {\n\t\tchoice := resp.Choices[0]\n\t\tresult.Content = choice.Message.Content\n\t\tresult.ReasoningContent = choice.Message.ReasoningContent\n\t\tresult.FinishReason = choice.FinishReason\n\n\t\t// Convert tool calls\n\t\tfor _, tc := range choice.Message.ToolCalls {\n\t\t\tresult.ToolCalls = append(result.ToolCalls, ToolCall{\n\t\t\t\tID: tc.ID,\n\t\t\t\tName: tc.Function.Name,\n\t\t\t\tArguments: tc.Function.Arguments,\n\t\t\t})\n\t\t}\n\t}\n\n\tif resp.Usage != nil {\n\t\tresult.Usage = &Usage{\n\t\t\tPromptTokens: resp.Usage.PromptTokens,\n\t\t\tCompletionTokens: resp.Usage.CompletionTokens,\n\t\t\tTotalTokens: resp.Usage.TotalTokens,\n\t\t}\n\t}\n\n\treturn result\n}\n\nfunc (o *OpenAI) doRequest(ctx context.Context, req *openAIRequest) ([]byte, error) {\n\tjsonData, err := json.Marshal(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to marshal request: %w\", err)\n\t}\n\treturn doHTTPWithRetry(ctx, o.client, \"OpenAI\",\n\t\tfunc() (*http.Request, error) {\n\t\t\treturn http.NewRequestWithContext(ctx, \"POST\", o.config.BaseURL, bytes.NewBuffer(jsonData))\n\t\t},\n\t\to.setHeaders,\n\t)\n}\n\nfunc (o *OpenAI) setHeaders(req *http.Request) {\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\tif o.config.APIKey != \"\" {\n\t\treq.Header.Set(\"Authorization\", \"Bearer \"+o.config.APIKey)\n\t}\n\n\tApplyCustomHeaders(req, o.config.Headers)\n}\n"}}}}