{"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"}}}}