// Package wire 提供内部共享协议适配器. // // openai.go - OpenAI Chat Completions SSE 客户端. // // 多家 provider 使用相同的 OpenAI 流式协议: // - OpenAI 官方 API // - OpenRouter(聚合网关,完全兼容 OpenAI) // - Ollama(本地部署,OpenAI 兼容模式) // - LM Studio(本地部署,OpenAI 兼容) // - MiniMax native API(SSE 格式与 OpenAI 相同,仅端点路径不同) // // 设计: // - 本包仅被 pkg/providers/* 调用,不对外暴露(internal 包规则) // - 接受 flyto.Request 输入,输出 flyto.Event channel // - provider 层只需配置 baseURL / headers / modelTable,不需要重复 SSE 解析逻辑 // // 关键修复(BUGFIX): reasoning/thinking 字段各家不同,存在三种格式: // 1. OpenAI o1/o3: `choices[0].delta.reasoning_content`(字符串) // 2. OpenRouter: `choices[0].delta.reasoning_details`(对象数组,含 type 字段) // 旧版代码用 `reasoning` 字段(非标准),两种格式都无法正确解析. // // 升华改进(ELEVATED): 早期实现 没有统一 OpenAI 兼容层--每个 provider // 各自实现完整的 SSE 解析,存在大量重复代码. // 我们将公共部分提取到此文件,各 provider 保持薄封装(<150行). // 替代方案:<各 provider 自行实现完整 SSE 解析> - 否决:4个 provider 各500行, // 任何 bug 修复都要改4处,维护成本线性增长. package wire import ( "bufio" "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "strings" "time" "git.flytoex.net/yuanwei/flyto-agent/core/internal/apierror" "git.flytoex.net/yuanwei/flyto-agent/core/internal/streamguard" "git.flytoex.net/yuanwei/flyto-agent/core/internal/transport/retry" "git.flytoex.net/yuanwei/flyto-agent/core/pkg/flyto" ) // L1330 (2026-04-13): maxErrorBodyBytes / maxStreamingBodyBytes 已迁移到 limits.go, // 统一导出为 MaxErrorBodyBytes / MaxStreamingBodyBytes, 消除 wire 和 transport/api 两处重复定义. // defaultResponseHeaderTimeout 是 newDefaultHTTPClient 的 safety net. // Provider 包 (openai/minimax/openrouter/ollama/lmstudio) 通过 WithResponseHeaderTimeout // 覆盖; 此常量只在 provider 不传选项时生效 (内部测试 / 直接调用场景). // // 精妙之处(CLEVER): 我们设置的是 http.Transport.ResponseHeaderTimeout, **不是** // http.Client.Timeout.后者约束"整个请求总时长"包括 SSE 流式 body 读取 - // 对 LLM 流式响应是致命的 (60s 超时会砍死 2-5 分钟的长回复).前者只约束 // "请求发出到收到响应首字节"的时间,流式 body 后续读取不受限. // 详见 internal/transport/client.go DefaultResponseHeaderTimeout 的反向思维段 - // 两处保持等价语义,避免不同 provider 对同一概念的行为漂移. const defaultResponseHeaderTimeout = 60 * time.Second // newDefaultHTTPClient 构造 wire 层默认 *http.Client, 带 ResponseHeaderTimeout 兜底. // 与 internal/transport/client.go 的同名 helper 并列: 两个包各自独立, 不跨包依赖. // 替代方案: <提取共享 helper 到 internal/httputil> - 否决: 只为 3 行 helper 引入 // 新包 + 跨包依赖, Go idiom 允许 "a little copying is better than a little dependency". func newDefaultHTTPClient() *http.Client { return &http.Client{ Transport: &http.Transport{ ResponseHeaderTimeout: defaultResponseHeaderTimeout, }, } } // OpenAICompatClient 是 OpenAI Chat Completions SSE 客户端. // // 支持: // - 流式文本输出(choices[].delta.content) // - 工具调用(choices[].delta.tool_calls) // - Thinking/Reasoning(choices[].delta.reasoning,OpenRouter 及部分 provider) // - mid-stream error 格式(OpenRouter 的错误推送方式) // - usage 字段(最后一个 chunk 中,通过 stream_options.include_usage 开启) type OpenAICompatClient struct { apiKey string baseURL string chatPath string // 默认 "/v1/chat/completions",MiniMax native 不同 httpClient *http.Client extraHeaders map[string]string // 如 OpenRouter 的 HTTP-Referer / X-Title // retryer wraps the pre-stream HTTP request with the same retry // machinery the anthropic transport path uses (5xx/429/529/connection // errors -> classify + backoff). Zero value is a no-op retryer until a // policy is installed in NewOpenAICompatClient, so retries are opt-in by // construction and never silently absent. // // retryer 用与 anthropic transport 路径同一套重试机器包裹 pre-stream HTTP // 请求 (5xx/429/529/连接错误 -> 分类 + 退避). 零值是不重试的 retryer, 直到 // NewOpenAICompatClient 装上 policy, 故重试由构造期显式启用, 绝不静默缺席. retryer retry.Retryer } // OpenAICompatOption 是 OpenAICompatClient 的配置选项. type OpenAICompatOption func(*OpenAICompatClient) // WithHTTPClient 注入自定义 HTTP 客户端(代理,超时等). // // 注意: WithHTTPClient 会替换整个 httpClient 包括 Transport.与 // WithResponseHeaderTimeout 推荐二选一使用, 见 provider.New() 实现. func WithHTTPClient(hc *http.Client) OpenAICompatOption { return func(c *OpenAICompatClient) { c.httpClient = hc } } // WithResponseHeaderTimeout 覆盖默认 http.Client 的 ResponseHeaderTimeout. // // 这是"请求发出到收到响应首字节"的时间上限, **不影响** SSE 流式 body 后续读取. // LLM provider 的正确超时语义: 捕捉服务端死等, 放行长流式输出. // // 精妙之处(CLEVER): 绝对不要用 http.Client.Timeout 代替此函数 - 那会砍死流式调用. // 详见 defaultResponseHeaderTimeout 常量注释. // // 安全兜底: 如果 httpClient.Transport 不是 *http.Transport (消费者自定义 RoundTripper // 或先调用了 WithHTTPClient 替换整个 client), 此 option silent no-op 不 panic. // provider.New() 约定二选一使用, no-op 只在 wire 包被其他路径滥用时触发. func WithResponseHeaderTimeout(d time.Duration) OpenAICompatOption { return func(c *OpenAICompatClient) { if t, ok := c.httpClient.Transport.(*http.Transport); ok && t != nil { t.ResponseHeaderTimeout = d } } } // HTTPClient 返回底层 *http.Client, 用于测试,introspection 或需要直接发请求的场景. // 升华改进(ELEVATED): 暴露此 getter 让 provider 包单元测试能断言超时配置是否正确传递. // 与 internal/transport/client.go 的同名 getter 对称. func (c *OpenAICompatClient) HTTPClient() *http.Client { return c.httpClient } // WithChatPath 覆盖 chat completions 路径(默认 /v1/chat/completions). // // MiniMax native API 使用 /v1/text/chatcompletion_v2, // 其 SSE 格式与 OpenAI 相同,只需修改路径即可复用本客户端. func WithChatPath(path string) OpenAICompatOption { return func(c *OpenAICompatClient) { c.chatPath = path } } // WithExtraHeader 添加额外的 HTTP 请求头. // // 用途: // - OpenRouter: HTTP-Referer,X-Title // - 企业内网 API 网关的自定义鉴权头 func WithExtraHeader(key, value string) OpenAICompatOption { return func(c *OpenAICompatClient) { if c.extraHeaders == nil { c.extraHeaders = make(map[string]string) } c.extraHeaders[key] = value } } // WithRetryPolicy 覆盖默认的 pre-stream HTTP 重试策略. // // WithRetryPolicy overrides the default pre-stream HTTP retry policy // installed by NewOpenAICompatClient. func WithRetryPolicy(p retry.RetryPolicy) OpenAICompatOption { return func(c *OpenAICompatClient) { c.retryer.Policy = p } } // NewOpenAICompatClient 创建 OpenAI 兼容客户端. func NewOpenAICompatClient(apiKey, baseURL string, opts ...OpenAICompatOption) *OpenAICompatClient { c := &OpenAICompatClient{ apiKey: apiKey, baseURL: strings.TrimRight(baseURL, "/"), chatPath: "/v1/chat/completions", httpClient: newDefaultHTTPClient(), } // Install a generic composite retry policy so the openai-compat family // gets the same pre-stream HTTP retry the anthropic transport path has. // A nil Policy makes retryer.Do return on the first failure (silent // no-op), so it MUST be set explicitly. General building blocks only // (foreground-vs-background 529 split + server directive + exponential // backoff) -- no anthropic-specific pieces (see NewAnthropicRetryPolicy // doc: other providers compose generic policies via NewCompositeRetryPolicy). // MaxRetries is kept conservative (4) vs the anthropic default (10): a // pre-stream request only needs to ride over a transient 5xx/429 window, // not hammer. // // 装通用 composite 重试策略, 让 openai-compat 家族获得与 anthropic transport // 路径同等的 pre-stream HTTP 重试. nil Policy 会让 retryer.Do 首次失败即返回 // (静默 no-op), 故必须显式装. 只用通用积木 (前台/后台 529 分流 + 服务端指令 // + 指数退避), 无 anthropic 特定件. MaxRetries 保守取 4 (anthropic 默认 10): // pre-stream 请求只需骑过瞬态 5xx/429 窗口, 不必猛捶. c.retryer.Policy = retry.NewCompositeRetryPolicy( &retry.ForegroundOnly{}, &retry.ServerDirective{}, &retry.ExponentialBackoff{ BaseDelay: 500 * time.Millisecond, MaxDelay: 32 * time.Second, MaxRetries: 4, JitterPct: 0.25, }, ) for _, opt := range opts { opt(c) } return c } // --- 请求 JSON 结构 --- // openaiRespFmt 是 response_format 请求字段. // // 精妙之处(CLEVER): OpenAI json_object 模式不需要 schema-- // 只声明"我要 JSON",模型自行保证输出可解析. // 与 Anthropic 的 json_schema 模式(需要完整 schema + Beta flag)不同, // 这是最小成本的结构化输出约束,适合探测和简单场景. type openaiRespFmt struct { Type string `json:"type"` // "json_object" | "json_schema" | "text" JSONSchema *openaiJSONSchema `json:"json_schema,omitempty"` // 仅 json_schema 类型时填充 } // openaiJSONSchema 是 OpenAI json_schema 模式的包装层. // // 升华改进(ELEVATED): OpenAI API 要求三层嵌套结构: // // response_format.json_schema.schema = <实际 JSON Schema> // // 而 flyto.ResponseFormat.JSONSchema 直接是原始 schema bytes. // 在 wire 层做包装,上层(flyto/engine)无需感知 OpenAI 的嵌套格式. // 替代方案:<在 flyto.ResponseFormat 里直接带上 name/strict 字段> - 否决: // 绑定了 OpenAI 格式细节,Anthropic/MiniMax 等不需要这些字段. type openaiJSONSchema struct { Name string `json:"name"` Strict bool `json:"strict"` Schema json.RawMessage `json:"schema"` } // openaiReq 是 Chat Completions 请求体. type openaiReq struct { Model string `json:"model"` Messages []openaiMsg `json:"messages"` MaxTokens int `json:"max_tokens,omitempty"` Tools []openaiTool `json:"tools,omitempty"` Stream bool `json:"stream"` StreamOptions *streamOptions `json:"stream_options,omitempty"` Reasoning *openaiReasoning `json:"reasoning,omitempty"` // OpenRouter / o1 系列 ResponseFormat *openaiRespFmt `json:"response_format,omitempty"` // Temperature / TopP: nil = omit field; passthrough policy. Upstream // validates range; OpenAI o-series / gpt-5 reasoning reject any non-1 // temperature with 4xx, which surfaces as ErrorEvent (no client-side // model-prefix list -- see ADR rule of two). // // Temperature / TopP: nil = wire 不传; passthrough 策略. 上游校验范围; // OpenAI o 系列 / gpt-5 reasoning 拒绝非 1 temperature 4xx 自然冒泡为 // ErrorEvent (不在客户端维护 model-prefix 列表 -- 见 ADR rule of two). Temperature *float64 `json:"temperature,omitempty"` TopP *float64 `json:"top_p,omitempty"` // TopK / MinP: non-standard sampling extras. Pointers + omitempty so an // explicit 0 (top_k disabled / min_p no-floor) is still sent while nil is // omitted. Honored only by backends that implement them (local vLLM / // oMLX, ds4); official OpenAI never receives them because callers leave // them nil for that host. // // TopK / MinP: 非标准采样扩展. 指针 + omitempty 让显式 0 (top_k 关 / min_p // 无地板) 仍发, nil 省略. 仅实现了它们的 backend (本地 vLLM / oMLX, ds4) 认; // 官方 OpenAI 永不收到 -- 调用方对该 host 留 nil. TopK *int `json:"top_k,omitempty"` MinP *float64 `json:"min_p,omitempty"` // ReasoningSplit asks the backend to emit reasoning on a SEPARATE channel // (reasoning_content / reasoning_details) instead of inlining a // ... block into content. MiniMax-specific param // (reasoning_split: true); other openai-compat backends ignore an unknown // field. Pointer + omitempty so nil = field omitted (default inline // behavior preserved for every non-MiniMax caller). // // ReasoningSplit 要求后端把 reasoning 走独立通道 (reasoning_content / // reasoning_details) 而非内联 ... 进 content. MiniMax 专属参数 // (reasoning_split: true); 其他 openai-compat 后端忽略未知字段. 指针 + // omitempty 让 nil = 不发 (对每个非 MiniMax 调用方保留默认内联行为). ReasoningSplit *bool `json:"reasoning_split,omitempty"` // Thinking / ReasoningEffort: DeepSeek V4 thinking-mode controls (top-level, // per /guides/thinking_mode). Thinking is the explicit on/off object, // ReasoningEffort the high/max lever (default high upstream). Pointers + // omitempty so nil = field omitted: only the DeepSeek provider sets them, // every other openai-compat caller leaves both nil and the body is byte- // identical to before. When thinking is enabled DeepSeek IGNORES // temperature/top_p/penalties -- documented upstream, not enforced here. // // Thinking / ReasoningEffort: DeepSeek V4 思考模式控制 (顶级, 官方 // /guides/thinking_mode). Thinking 是显式开关对象, ReasoningEffort 是 high/max // 杠杆 (上游默认 high). 指针 + omitempty 让 nil = 省略: 仅 DeepSeek provider 设它们, // 其他 openai-compat 调用方两者留 nil, body 与改动前逐字节一致. 思考开启时 // DeepSeek **忽略** temperature/top_p/penalty (上游文档, 此处不强制). Thinking *openaiThinking `json:"thinking,omitempty"` ReasoningEffort *string `json:"reasoning_effort,omitempty"` } type streamOptions struct { IncludeUsage bool `json:"include_usage"` } // openaiMsg 是 Chat Completions 请求中的消息. // // Content 使用 json.RawMessage 以支持两种格式: // - 纯文本:`"hello"` // - 多部分:`[{"type":"text","text":"hello"}]`(vision 场景) type openaiMsg struct { Role string `json:"role"` Content json.RawMessage `json:"content,omitempty"` ToolCalls []openaiToolCall `json:"tool_calls,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"` Name string `json:"name,omitempty"` // ReasoningContent: ADR-0007 capability-aware reasoning passback. // 仅当 ReasoningPassbackMode=="string" 时由 flytoMessagesToOpenAI // 写入 (DeepSeek-R1 / SiliconFlow 协议要求 prior assistant turn // 的 thinking 在 reasoning_content 字段回传). omitempty 让其他 // mode 路径不污染. // // 镜像 openaiChunk delta.reasoning_content 字段名 (line 292 同款). // OpenAI o1/o3 server 端管 state, mode="none" 时跳过 inject; OpenAI // 忽略未知字段不 reject. // // ReasoningContent: ADR-0007 capability-aware passback. 仅 mode= // "string" 时写入. 镜像 openaiChunk delta.reasoning_content 字段名. ReasoningContent string `json:"reasoning_content,omitempty"` } type openaiTool struct { Type string `json:"type"` // "function" Function openaiToolFn `json:"function"` } type openaiToolFn struct { Name string `json:"name"` Description string `json:"description"` Parameters json.RawMessage `json:"parameters"` // JSON Schema } type openaiToolCall struct { Index int `json:"index"` ID string `json:"id,omitempty"` Type string `json:"type,omitempty"` // "function" Function struct { Name string `json:"name,omitempty"` Arguments string `json:"arguments,omitempty"` } `json:"function"` } // openaiReasoningDetail 是 OpenRouter 的 reasoning_details 数组元素. // // 关键修复(BUGFIX): OpenRouter 的 thinking 不是单个字符串字段,而是对象数组: // // "reasoning_details": [{"type": "reasoning.text", "text": "..."}, ...] // // 类型列表(OpenRouter 文档): // - "reasoning.text" - 思考过程文本片段(最常见) // - "reasoning.summary" - 思考摘要(某些模型开启 reasoning_summary 时出现) // - "thinking" - 部分本地模型(Qwen)使用此 type 名 // // 与 OpenAI o1/o3 的 reasoning_content(字符串字段)不同,两者不可混淆. type openaiReasoningDetail struct { Type string `json:"type"` // "reasoning.text" / "reasoning.summary" / "thinking" Text string `json:"text"` } // Reasoning 是 OpenRouter / o1 系列 / MiniMax 的 thinking 参数(公开类型,供 provider 层使用). type Reasoning struct { MaxTokens int `json:"max_tokens,omitempty"` // Anthropic/Gemini/Qwen/MiniMax 模型 Effort string `json:"effort,omitempty"` // OpenAI o1/o3: "high"/"medium"/"low" Enabled bool `json:"enabled,omitempty"` // 简单开关(使用默认配置) } // openaiReasoning 是序列化用的内部别名(保持 JSON 字段名一致). type openaiReasoning = Reasoning // openaiThinking is the DeepSeek V4 thinking-mode control object, serialized // as top-level `{"thinking":{"type":"enabled"|"disabled"}}` (per // api-docs.deepseek.com/guides/thinking_mode). DISTINCT from the OpenRouter / // o1 `reasoning:{}` object above: DeepSeek V4 reasons by default and uses this // explicit on/off switch plus a top-level reasoning_effort, not a nested // reasoning object. Only the DeepSeek provider (ModeOpenAI) emits it; other // openai-compat backends leave StreamRequest.ThinkingType nil so the field is // omitted. // // openaiThinking 是 DeepSeek V4 思考模式控制对象, 序列化成顶级 // `{"thinking":{"type":"enabled"|"disabled"}}` (官方 /guides/thinking_mode). // 与上面 OpenRouter / o1 的 `reasoning:{}` 对象**不同**: DeepSeek V4 默认推理, // 用这个显式开关 + 顶级 reasoning_effort, 而非嵌套 reasoning 对象. 仅 DeepSeek // provider (ModeOpenAI) 发它; 其他 openai-compat 后端留 StreamRequest.ThinkingType // nil 故字段被省略. type openaiThinking struct { Type string `json:"type"` // "enabled" / "disabled" } // --- 响应 JSON 结构 --- // openaiChunk 是流式响应的单个 SSE 数据块. type openaiChunk struct { ID string `json:"id"` Object string `json:"object"` Model string `json:"model"` Choices []struct { Index int `json:"index"` Delta struct { Role string `json:"role"` Content string `json:"content"` // 关键修复(BUGFIX): reasoning/thinking 字段各家不同,存在两种格式-- // 旧版代码用 `reasoning`(非标准字段名),两种格式都无法正确解析. // // ReasoningContent: OpenAI o1/o3 原生格式,字符串字段. // 文档:platform.openai.com/docs/guides/reasoning ReasoningContent string `json:"reasoning_content"` // ReasoningDetails: OpenRouter 格式,对象数组(见 openaiReasoningDetail). // 多个 provider 经由 OpenRouter 转发时统一使用此格式. // 文档:openrouter.ai/docs/use-cases/reasoning-tokens ReasoningDetails []openaiReasoningDetail `json:"reasoning_details"` ToolCalls []openaiToolCall `json:"tool_calls"` } `json:"delta"` FinishReason *string `json:"finish_reason"` } `json:"choices"` // 精妙之处(CLEVER): usage 只在最后一个 chunk 出现(需要 stream_options.include_usage=true). // 我们每次都请求 include_usage=true,这样可以在流结束时获取完整用量统计, // 不需要在客户端自行累计 token 数(容易出错且对缓存 token 无能为力). Usage *struct { PromptTokens int `json:"prompt_tokens"` CompletionTokens int `json:"completion_tokens"` PromptTokensDetails *struct { CachedTokens int `json:"cached_tokens"` } `json:"prompt_tokens_details"` // CompletionTokensDetails 包含 o1/o3 的 reasoning_tokens(思考消耗的 token 数). // 目前仅记录,不单独上报给调用层(合并进 OutputTokens). CompletionTokensDetails *struct { ReasoningTokens int `json:"reasoning_tokens"` } `json:"completion_tokens_details"` // OpenRouter / MiniMax 特有的缓存写入字段 CacheWriteTokens int `json:"cache_write_tokens"` // DeepSeek-specific top-level cache fields. DeepSeek's // OpenAI-compat endpoint reports cache hits at usage.prompt_cache_hit_tokens // (NOT prompt_tokens_details.cached_tokens), so providers // going through this client must read both fields and let // buildUsageEvent's fallback pick the non-zero one. Other // OpenAI-compat backends leave this field unset (zero value // preserves zero-regression). See ADR-0007 § cache mapping. // // DeepSeek 专有的顶级 cache 字段. DeepSeek OpenAI 兼容端点把 // 缓存命中报在 usage.prompt_cache_hit_tokens (不是 // prompt_tokens_details.cached_tokens), 所以经此 client 的 // provider 必须双字段都解, 由 buildUsageEvent 的 fallback 选非零者. // 其他 OpenAI 兼容后端不设此字段 (零值保持零回归). 见 ADR-0007 § cache mapping. PromptCacheHitTokens int `json:"prompt_cache_hit_tokens"` PromptCacheMissTokens int `json:"prompt_cache_miss_tokens"` } `json:"usage"` // mid-stream 错误(OpenRouter 格式) Error *struct { Code int `json:"code"` Message string `json:"message"` } `json:"error"` } // StreamRequest 是发给 OpenAI 兼容端点的请求参数(公开给 provider 层). type StreamRequest struct { Model string Messages []flyto.Message System string // 系统提示(转换为 system role 消息) MaxTokens int Tools []flyto.Tool Reasoning *Reasoning // 可选 thinking 参数(OpenRouter / o1 系列 / MiniMax) ResponseFormat *flyto.ResponseFormat // 结构化输出格式(nil = 文本) // ThinkingBudget Gemini 专用 per-request thinking budget(其他 provider 忽略此字段). // > 0 时在 generationConfig.thinkingConfig 中传递. ThinkingBudget int // EnableSystemCaching 为系统消息添加 cache_control: ephemeral 标记(数组格式). // 用于 OpenRouter → Anthropic 路径的 prompt caching: // OpenRouter 将 cache_control 透传给 Anthropic,命中时 usage.cached_tokens > 0. // 注意:仅对支持 Anthropic 协议的后端有效(OpenAI 原生 API 会忽略此字段). EnableSystemCaching bool // Temperature / TopP: per-request sampling knobs. Nil = omit on the // wire (upstream uses its default). OpenAICompatClient.buildRequest // passes these straight to the openaiReq JSON; Gemini's buildRequest // embeds them under generationConfig. // // Temperature / TopP: 本次请求的采样旋钮. nil = wire 不传 (上游默认). // OpenAICompatClient.buildRequest 直接写入 openaiReq JSON; Gemini // 的 buildRequest 嵌入到 generationConfig 下. Temperature *float64 TopP *float64 // TopK / MinP: non-standard sampling extras (vLLM / oMLX / ds4 etc.). // Nil = omit. OpenAICompatClient.buildRequest writes them to openaiReq // as top_k / min_p; Gemini's buildRequest does NOT map them (tracked // gap), so a Gemini provider must leave these nil. Field doc on // flyto.Request.{TopK,MinP} carries the full per-provider mapping table. // // TopK / MinP: 非标准采样扩展 (vLLM / oMLX / ds4 等). nil = 不传. // OpenAICompatClient.buildRequest 写进 openaiReq 的 top_k / min_p; Gemini // 的 buildRequest **不**映射它们 (tracked gap), 故 Gemini provider 须留 nil. // 完整 per-provider 映射表见 flyto.Request.{TopK,MinP} 字段 doc. TopK *int MinP *float64 // ThinkingType / ReasoningEffort: DeepSeek V4 thinking-mode controls // (top-level thinking:{type} + reasoning_effort). ThinkingType is nil = // omit (use the model default; V4 = thinking ON), "enabled" / "disabled" = // force. ReasoningEffort = nil omit, "high" / "max" otherwise. Only the // DeepSeek provider sets these; every other openai-compat provider leaves // them nil so buildRequest emits a byte-identical body. See // flyto.Request.{ThinkingMode,Effort} for the full rationale (sampling is // silently no-op'd when thinking is ON, so a sampling-reliant sub-agent // must send "disabled"). // // ThinkingType / ReasoningEffort: DeepSeek V4 思考模式控制 (顶级 // thinking:{type} + reasoning_effort). ThinkingType nil = 省略 (用模型默认; // V4 = 思考开), "enabled" / "disabled" = 强制. ReasoningEffort nil 省略, // 否则 "high" / "max". 仅 DeepSeek provider 设它们; 其他 openai-compat provider // 留 nil 故 buildRequest 出逐字节一致的 body. 完整理由见 // flyto.Request.{ThinkingMode,Effort} (思考开时采样被静默 no-op, 故靠采样的 // sub-agent 必须发 "disabled"). ThinkingType *string ReasoningEffort *string // ReasoningSplit (MiniMax-specific): when true the provider asks MiniMax // to put reasoning in reasoning_content / reasoning_details -- a separate // channel ParseOpenAIStream already routes to ThinkingDeltaEvent -- instead // of inlining ... into content. Consumers then read clean // content for JSON extraction and never see the model's marker format // (engine-layer reasoning-normalization contract). Nil = omit (default // inline). Only the minimax provider sets this; other openai-compat // providers leave it nil. A general per-provider marker normalizer for // backends that inline AND cannot split server-side (local Gemma omlx // <|channel>, gpt-oss harmony) is tracked as a separate ADR. // // ReasoningSplit (MiniMax 专属): true 时 provider 让 MiniMax 把 reasoning 放进 // reasoning_content / reasoning_details -- 一条 ParseOpenAIStream 已路由成 // ThinkingDeltaEvent 的独立通道 -- 而非内联 ... 进 content. 消费者 // 因此读到干净 content 做 JSON 抽取, 永不接触模型的 marker 格式 (引擎层 // reasoning 归一契约). nil = 不传 (默认内联). 仅 minimax provider 设置, 其他 // openai-compat provider 留 nil. 针对"内联且无法服务端拆分"后端 (本地 Gemma // omlx <|channel>, gpt-oss harmony) 的通用 per-provider marker 归一器作独立 ADR. ReasoningSplit *bool // ReasoningPassbackMode declares whether prior assistant turn's // thinking should be re-injected into the next request's assistant // message reasoning_content field. Mirrors flyto.ModelInfo. // ReasoningPassbackMode ("" / "none" / "string" / "details_array"). // Provider 层从 req.Capabilities.ReasoningPassbackMode 读出后塞入 // StreamRequest, wire 层不直接消费 flyto.ModelInfo 避免循环 import. // // "" / "none" / "details_array" 都不 inject (后者 wire 此版本未实装). // "string" 模式 inject reasoning_content 字段 (DeepSeek-R1 协议). // // ReasoningPassbackMode 声明 prior assistant turn thinking 是否重新 // inject 到下一轮请求的 assistant message reasoning_content. 镜像 // flyto.ModelInfo.ReasoningPassbackMode. wire 层不直接消费 ModelInfo // 避免循环 import. ReasoningPassbackMode string // ToolNameRegex: 若非空, wire 层 pre-flight 校验每个 tool.Name 是否 // 命中此 regex; 不命中返 ErrModelToolUnsupported 早期拒绝避免 HTTP // 4xx 浪费 round trip. 镜像 flyto.ModelInfo.ToolNameRegex. // // ToolNameRegex: pre-flight 校验 tool 名 regex. 不命中早期拒绝. ToolNameRegex string } // Stream 向 OpenAI 兼容端点发起流式请求,返回 flyto.Event channel. // // channel 关闭表示流结束.最后一个事件可能是 *flyto.ErrorEvent(出错时) // 或 *flyto.UsageEvent(正常结束时). // // pre-stream 阶段 (HTTP 握手 + 首响应) 带重试, 镜像 anthropic transport 路径 // (internal/transport/client.go CreateMessageStream): retryer.Do 包裹 // doStreamOnce 直到"拿到 200 + 返回 channel"; 一旦返回 event channel, SSE 消费 // 在 channel 上进行, 不再重试 -- pre-stream 重试天然幂等 (还没吐任何 token). // 5xx/429/529/连接错误经 apierror.DefaultClassifier 分类为可重试; 4xx (鉴权/ // model 不存在/请求错) 不可重试立即返回. 重试耗尽后 toEngineError 转回 // flyto.EngineError 保 ErrProviderHTTPStatus typed code + Detail (ADR-0006 // fail-loud 链不变). // // The pre-stream phase (HTTP handshake + first response) is retried, mirroring // the anthropic transport path. Once the event channel is returned, SSE // consumption runs on it and is never retried -- pre-stream retry is naturally // idempotent (no token streamed yet). func (c *OpenAICompatClient) Stream(ctx context.Context, req *StreamRequest) (<-chan flyto.Event, error) { var eventCh <-chan flyto.Event rctx := &retry.RetryContext{ Model: req.Model, QuerySource: retry.QuerySourceFromCtx(ctx), } err := c.retryer.Do(ctx, rctx, func(attempt int, rctx *retry.RetryContext) error { ch, doErr := c.doStreamOnce(ctx, req) if doErr != nil { return doErr } eventCh = ch return nil }) if err != nil { return nil, c.toEngineError(err) } return eventCh, nil } // toEngineError 把 retryer.Do 返回的错误归一成对外的 *flyto.EngineError, 保留 // ADR-0006 的 typed code + Detail -- engine 层 errors.As 链不变. 输入可能是重试 // 耗尽的 CannotRetryError (包着 *apierror.APIError) / 不可重试直接返回的 // *apierror.APIError / 200-非-SSE 的 *flyto.EngineError / build 请求等原始错误. // // toEngineError normalizes the error returned by retryer.Do into an outward // *flyto.EngineError, preserving the ADR-0006 typed code + Detail. func (c *OpenAICompatClient) toEngineError(err error) error { // 200-非-SSE 已是 flyto.EngineError, 原样透出. var engErr *flyto.EngineError if errors.As(err, &engErr) { return engErr } // pre-stream HTTP / 连接错误: 从 apierror.APIError 取 statusCode + detail. var apiErr *apierror.APIError if errors.As(err, &apiErr) { msg := "openai_compat: http request" if apiErr.StatusCode > 0 { msg = fmt.Sprintf("openai_compat: http %d", apiErr.StatusCode) } return &flyto.EngineError{ Code: flyto.ErrProviderHTTPStatus, Message: msg, Detail: apiErr.Msg, } } // build/create request 等非分类错误, 原样. return err } // doStreamOnce 执行单次 HTTP 请求. 成功 (200 + SSE) 返回 event channel; 非 200 // 与网络错误返回实现 retry.RetryError 的 *apierror.APIError 供 retryer 决策; // 200-非-SSE (如 MiniMax 鉴权失败 200+JSON) 返回非 RetryError 的 flyto.EngineError, // retryer 见非 RetryError 不重试直接透出. func (c *OpenAICompatClient) doStreamOnce(ctx context.Context, req *StreamRequest) (<-chan flyto.Event, error) { body, err := c.buildRequest(req) if err != nil { return nil, fmt.Errorf("openai_compat: build request: %w", err) } httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+c.chatPath, bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("openai_compat: create request: %w", err) } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Accept", "text/event-stream") httpReq.Header.Set("Authorization", "Bearer "+c.apiKey) for k, v := range c.extraHeaders { httpReq.Header.Set(k, v) } resp, err := c.httpClient.Do(httpReq) if err != nil { // 网络错误 (statusCode 0): 分类为可重试的连接错误, 交 retryer 退避重试. // Network error (statusCode 0): classify as a retryable connection error. return nil, (&apierror.DefaultClassifier{}).Classify(0, nil, nil, err) } if resp.StatusCode != http.StatusOK { // ELEVATED (Bug U, ADR-0006 + pre-stream retry): 读 body 经 // parseNonSSEError 抽 provider 真错误 (OpenRouter 4xx 把底层错误塞进 // body.metadata.raw), 保留 fail-loud Detail 质量; 同时经 apierror. // DefaultClassifier 按 statusCode 定可重试性 (5xx/429/529 -> 可重试, // 4xx -> 不可重试), 让 retryer 骑过瞬态故障. apiErr 实现 retry.RetryError // 交 retryer.Do 决策; 最终对外形态由 toEngineError 转回 flyto.EngineError // 保 ErrProviderHTTPStatus typed code, engine 层 errors.As 链不变. // // 替代方案: <仅在 4xx 读 body 5xx 跳> -- 否决: 5xx 的 body 同样含 // provider 故障细节 (如 request_id), 一致处理让消费方拿到统一形态. // // ELEVATED (Bug U, ADR-0006 + pre-stream retry): read body via // parseNonSSEError for the provider error, classify via statusCode for // retryability, hand the RetryError to retryer.Do. body, _ := io.ReadAll(io.LimitReader(resp.Body, MaxErrorBodyBytes)) resp.Body.Close() apiErr := (&apierror.DefaultClassifier{}).Classify(resp.StatusCode, resp.Header, body, nil) if parsed := parseNonSSEError(body, resp.Header.Get("Content-Type")); parsed != nil { // openai 特定 detail 覆盖通用 message (不影响 statusCode 定的可重试性). apiErr.Msg = parsed.Error() } return nil, apiErr } // 精妙之处(CLEVER): 部分 provider(如 MiniMax)在鉴权失败时返回 HTTP 200 + JSON 错误体, // Content-Type 为 application/json 而非 text/event-stream. // SSE parser 找不到 data: 行,channel 静默关闭,调用层无法区分"空响应"和"错误". // 通过 Content-Type 提前检测,将错误体解析为 error 返回,避免误报. // MiniMax 格式: {"base_resp":{"status_code":2049,"status_msg":"invalid api key"}} // OpenAI 格式: {"error":{"message":"..."}} if ct := resp.Header.Get("Content-Type"); ct != "" && !strings.Contains(ct, "text/event-stream") { body, _ := io.ReadAll(io.LimitReader(resp.Body, MaxErrorBodyBytes)) resp.Body.Close() detail := "" if parsed := parseNonSSEError(body, ct); parsed != nil { detail = parsed.Error() } return nil, &flyto.EngineError{ Code: flyto.ErrProviderNonSSE, Message: fmt.Sprintf("openai_compat: 200 OK 但 Content-Type=%s (非 SSE)", ct), Detail: detail, } } ch := make(chan flyto.Event, 32) go func() { defer close(ch) defer resp.Body.Close() c.consumeSSE(ctx, resp, ch) }() // Wrap with StreamGuard so the openai-compat family gets the same // reliability detection (empty response / truncated-without-usage / idle // watchdog) the Anthropic-compat path already has. This realizes the // provider-neutral intent documented in streamguard's package doc; the // real logistics extraction flow runs on this path (self-hosted gemma4), // which previously streamed unguarded -- a silent LAN stall had no watchdog. // gemma4 emits a final usage chunk (verified), so the truncated check does // not false-positive on normal completion. // // 用 StreamGuard 包裹, 让 openai-compat 家族获得与 Anthropic-compat 路径同等 // 的可靠性检测 (空响应 / 无 usage 截断 / 空闲看门狗), 兑现 streamguard 包文档 // 写明的 provider 中性意图. 真实物流抽取流程跑的就是这条路 (自托管 gemma4), // 此前裸流无看门狗 -- LAN 静默挂死无人检测. gemma4 末尾发 usage chunk (已实 // 测), 故截断检测对正常结束不误报. return streamguard.NewStreamGuard(streamguard.DefaultStreamGuardConfig()).Watch(ctx, ch), nil } // buildRequest 将 StreamRequest 序列化为 JSON 请求体. func (c *OpenAICompatClient) buildRequest(req *StreamRequest) ([]byte, error) { msgs := flytoMessagesToOpenAI(req.Messages, req.System, req.EnableSystemCaching, req.ReasoningPassbackMode) var tools []openaiTool for _, t := range req.Tools { tools = append(tools, openaiTool{ Type: "function", Function: openaiToolFn{ Name: t.Name, Description: t.Description, Parameters: t.InputSchema, }, }) } r := openaiReq{ Model: req.Model, Messages: msgs, MaxTokens: req.MaxTokens, Tools: tools, Stream: true, // 精妙之处(CLEVER): 始终请求 include_usage-- // 这样最后一个 chunk 携带完整 usage,省去在客户端累计 token 的麻烦. // 额外开销:最后一个 chunk 多几十字节,完全可以接受. StreamOptions: &streamOptions{IncludeUsage: true}, Reasoning: req.Reasoning, Temperature: req.Temperature, TopP: req.TopP, TopK: req.TopK, MinP: req.MinP, ReasoningSplit: req.ReasoningSplit, ReasoningEffort: req.ReasoningEffort, } // DeepSeek V4 thinking:{type} object -- only when the provider set an // explicit on/off (nil ThinkingType = omit -> model default). // // DeepSeek V4 thinking:{type} 对象 -- 仅 provider 显式设了开关时发 // (nil ThinkingType = 省略 -> 模型默认). if req.ThinkingType != nil { r.Thinking = &openaiThinking{Type: *req.ThinkingType} } if req.ResponseFormat != nil { rf := &openaiRespFmt{Type: req.ResponseFormat.Type} if req.ResponseFormat.Type == "json_schema" && len(req.ResponseFormat.JSONSchema) > 0 { rf.JSONSchema = &openaiJSONSchema{ Name: "response", Strict: true, Schema: req.ResponseFormat.JSONSchema, } } r.ResponseFormat = rf } return json.Marshal(r) } // consumeSSE 从 HTTP 响应中读取 SSE 流并转换为 flyto.Event. func (c *OpenAICompatClient) consumeSSE(ctx context.Context, resp *http.Response, ch chan<- flyto.Event) { // 精妙之处(CLEVER): 1MB Scanner buffer-- // 工具调用的 arguments 字段可能累积到几百 KB(大型 JSON 操作), // 默认 64KB buffer 在这种情况下会静默截断,导致 JSON 解析失败. // 与其在发现问题后修 bug,不如预先分配合理上限. // // 升华改进(ELEVATED): io.LimitReader 包裹 resp.Body-- // 1MB 是 per-chunk 的 scanner buffer,但 total 流可能无限大. // LimitReader 在 100MB 处触发 EOF,scanner.Scan() 返回 false,goroutine 正常退出. // 替代方案:<不限制 total> - 否决:OOM 风险,恶意服务端可无限推送数据. scanner := bufio.NewScanner(io.LimitReader(resp.Body, MaxStreamingBodyBytes)) scanner.Buffer(make([]byte, 1024*1024), 1024*1024) // 工具调用状态:按 index 追踪每个工具调用的累积数据 type pendingToolCall struct { id string name string argumentBuf strings.Builder } toolCalls := make(map[int]*pendingToolCall) var textBuf strings.Builder var reasoningBuf strings.Builder var finishReason string for scanner.Scan() { // 升华改进(ELEVATED): 每轮检查 ctx 是否已取消-- // HTTP 底层未必立即断开(如长连接),ctx 取消后 scanner 仍可能 block 在 Read(). // 显式检查确保 goroutine 在 ctx 取消后一个 chunk 内退出,而非依赖底层隐式传播. // 替代方案:<只依赖 resp.Body.Close() 隐式触发 EOF> - 否决:TCP 半关闭场景下 // Body.Close() 不保证立即解除 Read() 阻塞,goroutine 可能 hang 数秒. if ctx.Err() != nil { return } line := scanner.Text() // OpenRouter 发送 `: OPENROUTER PROCESSING` 类型的 keepalive 注释,直接跳过 if strings.HasPrefix(line, ":") || line == "" { continue } if !strings.HasPrefix(line, "data: ") { continue } data := line[6:] if data == "[DONE]" { break } var chunk openaiChunk if err := json.Unmarshal([]byte(data), &chunk); err != nil { // 非标准 JSON,跳过(部分 provider 发送 ping 等非数据行) continue } // mid-stream 错误(OpenRouter 格式) // // ADR-0006 (Bug U fail-loud): 用 flyto.EngineError 带 typed // code 让 engine.ClassifyAPIError 走 errors.As 路径直接拿到 // ErrProviderMidStreamErr, 不再走字符串 fallback. Detail 携带 // provider 的具体 code + message 让消费方按字段索引. if chunk.Error != nil { engErr := &flyto.EngineError{ Code: flyto.ErrProviderMidStreamErr, Message: "openai_compat: provider mid-stream error", Detail: fmt.Sprintf("code=%d message=%s", chunk.Error.Code, chunk.Error.Message), } ch <- &flyto.ErrorEvent{ Err: engErr, Code: string(flyto.ErrProviderMidStreamErr), Detail: engErr.Detail, Retryable: chunk.Error.Code == 429 || chunk.Error.Code == 529, } return } if len(chunk.Choices) == 0 { // 可能是只有 usage 字段的最终 chunk if chunk.Usage != nil { ch <- buildUsageEvent(chunk, finishReason) } continue } choice := chunk.Choices[0] // 文本增量 if choice.Delta.Content != "" { textBuf.WriteString(choice.Delta.Content) ch <- &flyto.TextDeltaEvent{Text: choice.Delta.Content} } // Thinking/Reasoning 增量 - 兼容两种格式: // // 格式1: OpenAI o1/o3 原生--单个字符串字段 reasoning_content if choice.Delta.ReasoningContent != "" { reasoningBuf.WriteString(choice.Delta.ReasoningContent) ch <- &flyto.ThinkingDeltaEvent{Text: choice.Delta.ReasoningContent} } // 格式2: OpenRouter--reasoning_details 对象数组(见 openaiReasoningDetail) // 精妙之处(CLEVER): 用白名单 type 过滤而非直接取 text, // 避免把 "redacted"(思考被截断的占位符)等非文本 type 输出给用户. for _, rd := range choice.Delta.ReasoningDetails { if rd.Text == "" { continue } switch rd.Type { case "reasoning.text", "reasoning.summary", "thinking": reasoningBuf.WriteString(rd.Text) ch <- &flyto.ThinkingDeltaEvent{Text: rd.Text} } } // 工具调用增量 for _, tc := range choice.Delta.ToolCalls { ptc, exists := toolCalls[tc.Index] if !exists { ptc = &pendingToolCall{} toolCalls[tc.Index] = ptc } if tc.ID != "" { ptc.id = tc.ID } if tc.Function.Name != "" { ptc.name = tc.Function.Name } ptc.argumentBuf.WriteString(tc.Function.Arguments) } // finish_reason 非 nil 表示本 choice 结束 if choice.FinishReason != nil { finishReason = *choice.FinishReason // 发出完整文本事件 if textBuf.Len() > 0 { ch <- &flyto.TextEvent{Text: textBuf.String()} } // 发出完整 thinking 事件 if reasoningBuf.Len() > 0 { ch <- &flyto.ThinkingEvent{Text: reasoningBuf.String()} } // 发出工具调用事件 // 精妙之处(CLEVER): OpenAI 格式没有 content_block_stop 概念-- // 所有工具调用的 arguments 都在 finish_reason 出现后才算完整. // 与 Anthropic 格式(每个 block 单独有 stop 事件)不同, // 这里统一在 finish_reason 时批量发出所有工具调用事件. for i := 0; i < len(toolCalls); i++ { ptc, ok := toolCalls[i] if !ok { continue } argsStr := ptc.argumentBuf.String() var toolInput map[string]any if argsStr != "" && json.Valid([]byte(argsStr)) { _ = json.Unmarshal([]byte(argsStr), &toolInput) } if toolInput == nil { toolInput = make(map[string]any) } ch <- &flyto.ToolUseEvent{ ID: ptc.id, ToolName: ptc.name, Input: toolInput, } } // 发出 usage 事件(如果此 chunk 携带了 usage) if chunk.Usage != nil { ch <- buildUsageEvent(chunk, finishReason) } } } if err := scanner.Err(); err != nil && ctx.Err() == nil { ch <- &flyto.ErrorEvent{ Err: fmt.Errorf("openai_compat: sse scan error: %w", err), Code: "stream_error", Retryable: true, } } } // buildUsageEvent 从 chunk 中提取 usage 信息构造 UsageEvent. func buildUsageEvent(chunk openaiChunk, stopReason string) *flyto.UsageEvent { evt := &flyto.UsageEvent{StopReason: stopReason} if chunk.Usage != nil { evt.InputTokens = chunk.Usage.PromptTokens evt.OutputTokens = chunk.Usage.CompletionTokens if chunk.Usage.PromptTokensDetails != nil { evt.CacheReadTokens = chunk.Usage.PromptTokensDetails.CachedTokens } // DeepSeek 顶级 prompt_cache_hit_tokens fallback: DeepSeek 不走 // OpenAI 的 prompt_tokens_details.cached_tokens 嵌套, 走顶级字段. // 仅在嵌套字段未填时启用, 防止与未来可能两路都填的 provider 冲突. // DeepSeek top-level prompt_cache_hit_tokens fallback: only when // the OpenAI-style nested field is absent, avoiding double-count // if a future provider populates both. if evt.CacheReadTokens == 0 && chunk.Usage.PromptCacheHitTokens > 0 { evt.CacheReadTokens = chunk.Usage.PromptCacheHitTokens } evt.CacheCreationTokens = chunk.Usage.CacheWriteTokens } return evt } // --- 消息格式转换 --- // flytoMessagesToOpenAI 将 flyto.Message 列表转换为 OpenAI messages 格式. // // 精妙之处(CLEVER): flyto 和 OpenAI 的消息格式存在结构差异-- // Anthropic 格式中,工具结果以 "user" role 的 tool_result block 表示, // 且一个 user 消息可包含多个 tool_result block. // OpenAI 格式中,每个工具结果是独立的 "tool" role 消息,通过 tool_call_id 关联. // 转换规则:flyto BlockToolResult → 独立的 "tool" role OpenAI 消息. // 替代方案:<强制两种格式对齐,使 flyto.Message 无法表达多工具结果> - // 否决:限制了 flyto 格式的表达能力,其他 provider 无此限制. // // cacheSystem=true 时,系统消息使用数组格式并添加 cache_control: ephemeral-- // OpenRouter 将此标记透传给 Anthropic,命中缓存时 usage.cached_tokens > 0. // 普通字符串格式(cacheSystem=false)对 OpenAI 原生 API 透明,不产生副作用. func flytoMessagesToOpenAI(msgs []flyto.Message, systemPrompt string, cacheSystem bool, reasoningPassbackMode string) []openaiMsg { var result []openaiMsg if systemPrompt != "" { if cacheSystem { // 升华改进(ELEVATED): OpenRouter Anthropic caching 要求系统消息使用 content 数组格式, // 并在最后一个 block 上打 cache_control: ephemeral 断点. // 普通字符串格式("content": "...")无法携带 cache_control,缓存永远不会建立. // 替代方案:<在消息层之外通过 beta header 开启 caching> - // 否决:OpenRouter 不支持透传 Anthropic beta header,只能靠 content block 标记. type cacheBlock struct { Type string `json:"type"` Text string `json:"text"` CacheControl map[string]string `json:"cache_control"` } blocks := []cacheBlock{{ Type: "text", Text: systemPrompt, CacheControl: map[string]string{"type": "ephemeral"}, }} raw, _ := json.Marshal(blocks) result = append(result, openaiMsg{Role: "system", Content: raw}) } else { raw, _ := json.Marshal(systemPrompt) result = append(result, openaiMsg{Role: "system", Content: raw}) } } for _, msg := range msgs { switch msg.Role { case flyto.RoleUser: // 拆分:tool_result → "tool" 消息,text → "user" 消息 // 顺序:工具结果先于新的用户文本(符合 OpenAI 的对话流语义) for _, b := range msg.Blocks { if b.Type == flyto.BlockToolResult { raw, _ := json.Marshal(b.ResultText) result = append(result, openaiMsg{ Role: "tool", Content: raw, ToolCallID: b.ToolUseID, }) } } var textParts []string for _, b := range msg.Blocks { if b.Type == flyto.BlockText && b.Text != "" { textParts = append(textParts, b.Text) } } if len(textParts) > 0 { raw, _ := json.Marshal(strings.Join(textParts, "\n")) result = append(result, openaiMsg{Role: "user", Content: raw}) } case flyto.RoleAssistant: var textParts []string var thinkingParts []string var toolCalls []openaiToolCall // LEGACY (Bug W, 2026-05-01): tool_call_id dedupe transport- // level defense. r25 实证 OpenRouter→DeepSeek 在 strict // 协议下 reject "Duplicate value for tool_call_id ... in // message[N]" — 模型在 final response 内重复 emit 同一个 // tool_use block (与 engine.go:5037 detectDuplicateTextBlocks // 同源 model 输出纪律漂移), 引擎层 message accumulation 把 // 重复块照样塞进 msg.Blocks, wire 层若不去重 emit 两条 // toolCall 就被严协议 4xx. OpenAI/Anthropic 默认松容忍不 // reject 是 silent acceptance, 但 DeepSeek 严. Transport- // level dedup 让所有 provider 路径行为一致. // // 真因在引擎层重复 emit (engine 层 root cause 调研登记 // TD-19 ADR-0007 follow-up): 是 retry 路径没清 stale // assistantContent, 还是模型 final response 内重复了, 还 // 是 SSE 流式重组按 Index 不按 ID 唯一性导致 — 要 dump // + 复现实证. wire 层先做防御性 dedup 让 transport 不再 // 发 4xx, 引擎层 root cause 修复独立 follow-up. // // 替代方案: <在 wire 层 dedup 时 emit WarningEvent> // 否决: flytoMessagesToOpenAI 是纯转换函数无事件 channel; // 加返回参数不优雅. dedup 行为本身是 transport 防御不是 // 业务异常, 跟 SSE 重组 by Index 同档默默处理. // // 替代方案: <让 engine 层 message accumulation 直接 dedup // BlockToolUse 不让 wire 看到重复> 否决: wire 层 dedup // 是末端防御, 即使 engine 层未来又有 bug 也兜住; 跟 ADR- // 0006 fail-loud 不冲突 (这不是吞错是去重重复块, 真错 // 还是会冒泡). // // LEGACY (Bug W, 2026-05-01): tool_call_id 去重 transport // 层防御. r25 实证模型 final response 内重复 emit 同一 // tool_use block (与 engine.go:5037 同源纪律漂移), wire // 层去重让所有 provider 路径行为一致, 避免 DeepSeek 严 // 协议 4xx. 真因在 engine 层登记 TD-19 ADR-0007 follow-up. seenToolCallIDs := make(map[string]bool, len(msg.Blocks)) for i, b := range msg.Blocks { switch b.Type { case flyto.BlockText: textParts = append(textParts, b.Text) case flyto.BlockThinking: // ADR-0007 capability-aware passback: 累积 prior // assistant turn 的 thinking 文本待 emit 决定后写入 // reasoning_content 字段. 是否 emit 由调用方决定 // (StreamRequest.ReasoningPassbackMode), 非 "string" // 模式时累积值会被丢弃 — wire 层不知道 capability, // 这是 buildRequest 调用方传 mode 后判断. // // ADR-0007 capability-aware passback: 累积 prior // assistant turn thinking 待 emit 决定; mode!="string" // 时丢弃. if b.ThinkingText != "" { thinkingParts = append(thinkingParts, b.ThinkingText) } case flyto.BlockToolUse: if b.ToolUseID != "" && seenToolCallIDs[b.ToolUseID] { // Duplicate within this message -- skip silently. // Engine-level root cause logged as TD-19. // // 单 message 内 tool_use_id 重复 -- silent skip. // 引擎层 root cause 登记 TD-19. continue } if b.ToolUseID != "" { seenToolCallIDs[b.ToolUseID] = true } argsJSON, _ := json.Marshal(b.ToolInput) toolCalls = append(toolCalls, openaiToolCall{ Index: i, ID: b.ToolUseID, Type: "function", Function: struct { Name string `json:"name,omitempty"` Arguments string `json:"arguments,omitempty"` }{ Name: b.ToolName, Arguments: string(argsJSON), }, }) } } m := openaiMsg{Role: "assistant"} if len(textParts) > 0 { raw, _ := json.Marshal(strings.Join(textParts, "\n")) m.Content = raw } // ADR-0007 capability-aware passback: 仅当调用方 (provider 层) // 通过 reasoningPassbackMode 显式声明 "string" 时 inject // reasoning_content 字段. "" / "none" / "details_array" 都不 // inject (前两者 server 不要, 后者 wire 此版本未实装数组形态 // follow-up TD-21). // // 驱动: r24 实证 deepseek-v4-flash HTTP 400 "reasoning_content // must be passed back". flytoMessagesToOpenAI 的 capability // 信号由调用方注入参数 (StreamRequest.ReasoningPassbackMode), // wire 包不依赖 flyto.ModelInfo 字段 (避免循环 import). // // ADR-0007 capability-aware passback: 仅 mode=="string" 时 // inject reasoning_content. 其他 mode 跳过 (零回归). if len(thinkingParts) > 0 && reasoningPassbackMode == "string" { m.ReasoningContent = strings.Join(thinkingParts, "\n") } if len(toolCalls) > 0 { m.ToolCalls = toolCalls } result = append(result, m) } } return result } // --- 模型列表获取 --- // FetchOpenAIModels 从 /v1/models 端点获取模型列表(OpenAI 格式). // // 适用于:OpenAI 官方 API,LM Studio. // Ollama 使用不同的 /api/tags 端点,见 FetchOllamaModels. func (c *OpenAICompatClient) FetchOpenAIModels(ctx context.Context) ([]flyto.ModelInfo, error) { httpReq, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/v1/models", nil) if err != nil { return nil, err } httpReq.Header.Set("Authorization", "Bearer "+c.apiKey) for k, v := range c.extraHeaders { httpReq.Header.Set(k, v) } resp, err := c.httpClient.Do(httpReq) if err != nil { return nil, err } defer resp.Body.Close() var body struct { Data []struct { ID string `json:"id"` OwnedBy string `json:"owned_by"` // ModelType is an oMLX catalog extension ("vlm" / "audio_stt" / // ...); real OpenAI omits it, leaving capability flags false. // ModelType 是 oMLX 目录扩展 ("vlm" / "audio_stt" / ...); 真 OpenAI // 不带, 能力位保持 false. ModelType string `json:"model_type"` } `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { return nil, err } var models []flyto.ModelInfo for _, m := range body.Data { models = append(models, flyto.ModelInfo{ ID: m.ID, DisplayName: m.ID, Provider: m.OwnedBy, // Map the self-hosted catalog's model_type onto the shared // capability flags so the model spec carries what the model // actually is (ADR-0018 tier 2 live discovery). // 把自托管目录的 model_type 映射到共享能力位, 让模型 spec 承载 // 模型真实身份 (ADR-0018 第二档 live discovery). SupportsVision: m.ModelType == "vlm", SupportsTranscription: m.ModelType == "audio_stt", }) } return models, nil } // FetchOllamaModels 从 Ollama 的 /api/tags 端点获取本地模型列表. func (c *OpenAICompatClient) FetchOllamaModels(ctx context.Context) ([]flyto.ModelInfo, error) { httpReq, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/tags", nil) if err != nil { return nil, err } resp, err := c.httpClient.Do(httpReq) if err != nil { return nil, err } defer resp.Body.Close() var body struct { Models []struct { Name string `json:"name"` Details struct { ParameterSize string `json:"parameter_size"` QuantizationLevel string `json:"quantization_level"` } `json:"details"` } `json:"models"` } if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { return nil, err } var models []flyto.ModelInfo for _, m := range body.Models { displayName := m.Name if m.Details.ParameterSize != "" { displayName = fmt.Sprintf("%s (%s %s)", m.Name, m.Details.ParameterSize, m.Details.QuantizationLevel) } models = append(models, flyto.ModelInfo{ ID: m.Name, DisplayName: displayName, Provider: "ollama", SupportsVision: false, // Ollama 运行时无法可靠判断,保守返回 false }) } return models, nil } // FetchOpenRouterModels 从 OpenRouter 的 /api/v1/models 端点获取模型列表. // // ADR-0007 (capability tracking 接入纪律, 2026-05-01) 扩展消费 OpenRouter // live API 高价值字段, 不再仅消费 ContextLength + Pricing 2 字段: // // - architecture.input_modalities []string -> SupportsVision/Audio/PDF // 自动判 (旧版仅靠 documentedCapabilities 27 条手填表) // - top_provider.max_completion_tokens (实际后端上界, 旧版用 root // m.MaxCompletionTokens 是聚合上界与实际后端不一致) // - pricing.input_cache_read -> SupportsCaching + 价 // - supported_parameters[] 完整数组 (15 项): tools / tool_choice / // response_format / seed / frequency_penalty 等 // - ProviderKind="aggregator" 自动标 (OpenRouter 本身就是聚合) // - ToolNameRegex `^[a-zA-Z0-9_-]+$` 默认 (OpenAI 兼容协议下的最小 // 公约数, 各底层 provider 严更严, 松不会更松) // - ReasoningPassbackMode 暂留 "" 不预设 (各底层模型协议差异大, // 由 capability-probe 实测后填; 留 follow-up TD-22) // // 业界对照: LiteLLM 维护 model_prices_and_context_window.json 静态表 // 30+ 字段; OpenRouter live API 已经返同档信息但 Flyto 此前几乎全 // 丢弃. 本 commit 让 wire 层运行时拉取替代静态表 -- aggregator 路径 // 不必预先 RegisterModels 也能 capability-aware. // // FetchOpenRouterModels 从 OpenRouter live API 拉模型列表 (ADR-0007 // 扩展消费 architecture / top_provider / supported_parameters 等高价值 // 字段, 不再仅消费 2 字段). func (c *OpenAICompatClient) FetchOpenRouterModels(ctx context.Context) ([]flyto.ModelInfo, error) { httpReq, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/models", nil) if err != nil { return nil, err } httpReq.Header.Set("Authorization", "Bearer "+c.apiKey) for k, v := range c.extraHeaders { httpReq.Header.Set(k, v) } resp, err := c.httpClient.Do(httpReq) if err != nil { return nil, err } defer resp.Body.Close() var body struct { Data []struct { ID string `json:"id"` Name string `json:"name"` ContextLength int `json:"context_length"` MaxCompletionTokens int `json:"max_completion_tokens"` Pricing struct { Prompt string `json:"prompt"` // per-token 价格(字符串) Completion string `json:"completion"` InputCacheRead string `json:"input_cache_read"` // ADR-0007: 自动判 SupportsCaching } `json:"pricing"` SupportedParameters []string `json:"supported_parameters"` Architecture struct { // ADR-0007: input_modalities 数组 (text/image/audio/video/file) // 自动判 SupportsVision/Audio/PDF 不必手填表. InputModalities []string `json:"input_modalities"` } `json:"architecture"` TopProvider struct { // ADR-0007: top_provider.max_completion_tokens 是实际 // 后端上界, 优先于 root m.MaxCompletionTokens (后者是 // 聚合上界与实际后端不一致). MaxCompletionTokens int `json:"max_completion_tokens"` } `json:"top_provider"` } `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { return nil, err } var models []flyto.ModelInfo for _, m := range body.Data { // ADR-0007: top_provider.max_completion_tokens 优先 (实际后端 // 上界), root m.MaxCompletionTokens 是聚合 fallback. maxOut := m.TopProvider.MaxCompletionTokens if maxOut == 0 { maxOut = m.MaxCompletionTokens } info := flyto.ModelInfo{ ID: m.ID, DisplayName: m.Name, Provider: "openrouter", ContextWindow: m.ContextLength, MaxOutputTokens: maxOut, // 精妙之处(CLEVER): OpenRouter 定价是 per-token(字符串),需转为 per-1M-- // 用 parseOpenRouterPrice 将 "0.000003" 转为 3.0(USD/1M tokens). InputPricePer1M: parseOpenRouterPrice(m.Pricing.Prompt), OutputPricePer1M: parseOpenRouterPrice(m.Pricing.Completion), CacheReadPricePer1M: parseOpenRouterPrice(m.Pricing.InputCacheRead), // ADR-0007 ProviderKind="aggregator" 默认 -- OpenRouter 本身 // 是聚合网关, ADR-0007 § 2.2 bifurcation 保留 aggregator 走 // 兜底不走 strict. ProviderKind: "aggregator", // ADR-0007 ToolNameRegex 默认 OpenAI 兼容最小公约数. 各底层 // provider 严更严松不更松, 此 regex 让 wire pre-flight 早期 // 拒绝违规名 (r22 实证 SiliconFlow 路径同款). ToolNameRegex: `^[a-zA-Z0-9_-]+$`, // ADR-0007 ReasoningPassbackMode 暂留 "" 不预设 -- 各底层 // 模型协议差异大 (DeepSeek-R1 string / OpenAI o1 none / // 部分模型 details_array), 由 capability-probe 实测后填 // (TD-22 follow-up). 留空让 wire 跳过 inject 保零回归. } // 从 supported_parameters 推断能力 (扩展自旧版仅 reasoning/tools) for _, p := range m.SupportedParameters { switch p { case "reasoning": info.SupportsThinking = true } } // ADR-0007 input_modalities 自动判 SupportsVision (image/video). // 暂只接 vision, audio/PDF 等扩 ModelInfo 字段时同步 (TD-23). for _, mod := range m.Architecture.InputModalities { if mod == "image" || mod == "video" { info.SupportsVision = true break } } // ADR-0007 SupportsCaching 自动判: pricing.input_cache_read 非 // 空且非 "0" 即支持. parseOpenRouterPrice("0") 返 0 与未填空 // 一致, 用 raw 字符串判更严. if m.Pricing.InputCacheRead != "" && m.Pricing.InputCacheRead != "0" { info.SupportsCaching = true } models = append(models, info) } return models, nil } // parseNonSSEError extracts a human-readable error from a non-SSE // response body. // // Supports three common formats: // - OpenAI / OpenRouter top-level: {"error":{"message":"..."}} // - MiniMax: {"base_resp":{"status_code":N,"status_msg":"..."}} // - OpenRouter nested error.metadata.raw: the wrapper keeps // error.message="Provider returned error" and stuffs the // underlying provider's body (often itself JSON) into metadata.raw. // We try to unwrap it once -- if metadata.raw parses as JSON with // a recognizable shape, we surface that message instead of the // generic wrapper. // // ELEVATED (Bug U, 2026-05-01): the nested-raw branch was the missing // piece in r22 -- OpenRouter forwarded a SiliconFlow HTTP 400 // "Invalid 'tools[0].function.name'" inside metadata.raw, but we only // surfaced "Provider returned error" upstream, which then fell into // engine ClassifyAPIError default = ErrInternal "API 调用失败" and // the operator saw nothing actionable. Unwrapping one level of nesting // is enough to surface > 95% of OpenRouter pass-through errors; // deeper nesting (raw containing raw) is rare and we deliberately do // not recurse to keep the surface small + predictable. // // 替代方案: <让 OpenRouter provider 层判断 model + 调下游 API 解析> - // 否决: provider 层不应感知 wire 层 raw body 形态; 一旦 OpenRouter 改 // metadata schema 两处都得改. 在 wire 层 best-effort 解一层是消费方 // 零负担 + 失败兜底回 wrapper message 不破坏既有契约. // // If nothing matches, return body's first 256 bytes as raw preview. // // parseNonSSEError 从非 SSE 响应体中提取人类可读的错误信息. // // 支持三种常见格式: // - OpenAI / OpenRouter 顶层: {"error":{"message":"..."}} // - MiniMax: {"base_resp":{"status_code":N,"status_msg":"..."}} // - OpenRouter 嵌套 error.metadata.raw: wrapper 保留 // error.message="Provider returned error" 把底层 provider 真错误 // (常本身是 JSON) 塞进 metadata.raw. 试解一层 -- raw 解 JSON 命中 // 可识别形态时, 暴露该消息而非 wrapper 通用文案. // // ELEVATED (Bug U, 2026-05-01): 嵌套 raw 分支是 r22 缺的那一块 -- // OpenRouter 把 SiliconFlow HTTP 400 "Invalid 'tools[0].function.name'" // 塞进 metadata.raw, 我们只把 "Provider returned error" 上抛, 引擎 // ClassifyAPIError 默认走 ErrInternal "API 调用失败", 调用方看不到任何 // actionable 信息. 解一层嵌套足以暴露 > 95% OpenRouter 透传错; 更深 // 嵌套 (raw 内还有 raw) 罕见, 故意不递归保持 surface 小且可预测. // // 无任何命中时返回 body 前 256 字节原始预览. func parseNonSSEError(body []byte, contentType string) error { var errJSON struct { Error *struct { Message string `json:"message"` Metadata *struct { Raw string `json:"raw"` ProviderName string `json:"provider_name"` } `json:"metadata"` } `json:"error"` BaseResp *struct { StatusCode int `json:"status_code"` StatusMsg string `json:"status_msg"` } `json:"base_resp"` } if json.Unmarshal(body, &errJSON) == nil { if errJSON.Error != nil { // Try OpenRouter nested metadata.raw first -- the wrapper // message is usually generic ("Provider returned error"), // but the raw payload from the underlying provider names // the actual problem. // // 优先解 OpenRouter 嵌套 metadata.raw -- wrapper 消息通常 // 通用化 ("Provider returned error"), 而底层 provider 真错 // 信息在 raw 里. if errJSON.Error.Metadata != nil && errJSON.Error.Metadata.Raw != "" { if msg := extractOpenRouterRawMessage(errJSON.Error.Metadata.Raw); msg != "" { provider := errJSON.Error.Metadata.ProviderName if provider == "" { provider = "unknown" } return fmt.Errorf("openai_compat: provider error (via openrouter→%s): %s", provider, msg) } } if errJSON.Error.Message != "" { return fmt.Errorf("openai_compat: provider error: %s", errJSON.Error.Message) } } if errJSON.BaseResp != nil && errJSON.BaseResp.StatusCode != 0 { return fmt.Errorf("openai_compat: base_resp %d: %s", errJSON.BaseResp.StatusCode, errJSON.BaseResp.StatusMsg) } } preview := body if len(preview) > 256 { preview = preview[:256] } return fmt.Errorf("openai_compat: unexpected non-SSE response (Content-Type: %s): %s", contentType, preview) } // extractOpenRouterRawMessage parses one level of OpenRouter // metadata.raw and returns a human-readable message. // // Common shapes seen from OpenRouter's underlying providers: // - SiliconFlow / DeepSeek: {"code":N,"message":"...","data":null} // - OpenAI passthrough: {"error":{"message":"...","type":"..."}} // - Plain text (rare): "some error text" // // Returns "" when the shape is unrecognizable -- caller falls back to // the wrapper's generic message. // // extractOpenRouterRawMessage 解一层 OpenRouter metadata.raw 返回人类 // 可读消息. 形态无法识别时返回 "" 让调用方回退到 wrapper 通用消息. func extractOpenRouterRawMessage(raw string) string { // SiliconFlow / DeepSeek shape: {"code":N,"message":"..."}. // SiliconFlow / DeepSeek 形态: {"code":N,"message":"..."}. var sf struct { Code int `json:"code"` Message string `json:"message"` } if json.Unmarshal([]byte(raw), &sf) == nil && sf.Message != "" { if sf.Code != 0 { return fmt.Sprintf("[%d] %s", sf.Code, sf.Message) } return sf.Message } // OpenAI passthrough shape: {"error":{"message":"..."}}. // OpenAI passthrough 形态: {"error":{"message":"..."}}. var oa struct { Error *struct { Message string `json:"message"` Type string `json:"type"` } `json:"error"` } if json.Unmarshal([]byte(raw), &oa) == nil && oa.Error != nil && oa.Error.Message != "" { if oa.Error.Type != "" { return fmt.Sprintf("(%s) %s", oa.Error.Type, oa.Error.Message) } return oa.Error.Message } return "" } // parseOpenRouterPrice 将 OpenRouter 的 per-token 价格字符串转换为 per-1M USD. // // OpenRouter 价格格式:"0.000003"(每 token 的 USD 价格). // 我们统一用 per-1M tokens 的 USD 价格. func parseOpenRouterPrice(s string) float64 { if s == "" || s == "0" { return 0 } var v float64 // 精妙之处(CLEVER): 用 fmt.Sscanf 而非 strconv.ParseFloat, // 因为 OpenRouter 偶尔返回 "N/A" 或空字符串,ParseFloat 会返回 error, // Sscanf 在 0 个成功 scan 时直接返回 0,不需要 err 检查. fmt.Sscanf(s, "%f", &v) return v * 1_000_000 }