// 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
}