Files
Cyrene_v2/internal/runtime/model/openai.go
T
AskaEth 8890c30bb5
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(model): 按 provider 形状独立适配推理开关 + per-persona 提供商链真接线(裁决 03.4.2/08.4.1/03.4.1 余项)
- 新增 reasoning_shape.go:**每个 provider 形状一个 resolver,只发自己形状的字段**
  · deepseek:thinking{type} + reasoning_effort(依据 DevDocs/reference/deepseek 官方本地副本)
  · vllm:chat_template_kwargs.enable_thinking · dashscope:顶层 enable_thinking(**只允许显式声明**)
  · openai:仅 reasoning_effort · anthropic:自己的 thinking{budget_tokens}(未改)
  · 未知 kind → WARN 且一个字段都不发(不许静默失效)
- 形状载体:models.json 组级 `reasoning.kind`(可热更新,未声明则不写该键)+ env `LLM_REASONING_KIND`
- 红线:`effort:none` 在只有布尔开关的形状上翻成**显式关思考**,绝不降成任何强度档
- 03.4.1 余项:`Request.PersonaID` + `RuntimeLLM.ForPersona(pid)` 视图;
  每 persona 协议一条链并消费 `RouteProviderForPersona`;`agent/request.go` 非流式路径同一钩子
- **生产装配(主会话补)**:默认 agent 与 personaAgentPool 都改用 `ForPersona(pid)` 视图——
  否则角色级模型/提供商路由在生产里永不被选中(形同虚设)
- 复核:build/vet 绿 + model/agent/config/cmd 包测试绿;5 条变异反证先红后还原
  (其中一条「红但没钉住」已改用例:通道读取加 2s 超时,避免整包 panic 掩盖信号)
- 未落:DashScope 的 auto 推断(缺官方依据,只允许显式声明);Qwen `/think` 前导消息
  (design/01 §2.1 与 design/11 §3 口径冲突,停手报告)
2026-10-04 12:57:55 +08:00

244 lines
7.4 KiB
Go

package model
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// Wire constraints for the OpenAI-compatible /chat/completions protocol.
const (
// sseDataPrefix marks an SSE event line carrying a JSON payload.
sseDataPrefix = "data: "
// sseDone is the terminal SSE sentinel.
sseDone = "[DONE]"
// sseBufInit is the initial bufio.Scanner read buffer size.
sseBufInit = 64 * 1024
// maxSSELineBytes caps a single SSE event line.
maxSSELineBytes = 1024 * 1024
// errBodyCap bounds how much of an error body is read for the message.
errBodyCap = 512
// defaultHTTPTimeout bounds a single provider call when Options.Timeout is 0.
defaultHTTPTimeout = 180 * time.Second
)
// OpenAIChatAdapter calls an OpenAI-compatible /chat/completions endpoint. It is
// constructed from a config.LLMConfig and implements Adapter for both
// non-streaming and token-level SSE streaming.
type OpenAIChatAdapter struct {
name string
baseURL string
apiKey string
model string
client *http.Client
recorder Recorder
// reasoningKind is this endpoint's configured reasoning-switch shape
// (models.json group reasoning.kind / LLM_REASONING_KIND, 裁决 03.4.2/08.4.1).
// Empty means "auto": infer the vendor from the model name/base URL and send
// nothing when the shape is ambiguous.
reasoningKind string
// log reports reasoning_effort validation and ignored thinking directives. It
// is never nil: a discard logger tagged with this adapter's source is
// installed when the caller supplies none, so a decision is always traceable.
log *slog.Logger
}
// OpenAIOption configures an OpenAIChatAdapter.
type OpenAIOption func(*OpenAIChatAdapter)
// WithHTTPClient overrides the default HTTP client (used to inject mocks or a
// tuned transport in tests and production).
func WithHTTPClient(c *http.Client) OpenAIOption {
return func(a *OpenAIChatAdapter) { a.client = c }
}
// WithRecorder wires a per-call Recorder for CallRecord accounting.
func WithRecorder(r Recorder) OpenAIOption {
return func(a *OpenAIChatAdapter) { a.recorder = r }
}
// WithModel overrides the adapter's default model name.
func WithModel(m string) OpenAIOption {
return func(a *OpenAIChatAdapter) { a.model = m }
}
// WithOpenAILogger attaches the logger used to report reasoning_effort
// validation and ignored thinking directives. A nil logger is ignored so the
// discard default stays in place.
func WithOpenAILogger(l *slog.Logger) OpenAIOption {
return func(a *OpenAIChatAdapter) {
if l != nil {
a.log = l
}
}
}
// NewOpenAIChat builds an adapter from a supplier config. baseURL is trimmed of
// any trailing slash before the /chat/completions path is appended. The
// endpoint's configured reasoning shape (cfg.ReasoningKind) travels with the
// adapter so every chain built from the same config — the group's chain, its
// per-persona chain and the local rung — resolves its reasoning switches the
// same way.
func NewOpenAIChat(cfg config.LLMConfig, opts ...OpenAIOption) *OpenAIChatAdapter {
a := &OpenAIChatAdapter{
name: "openai",
baseURL: strings.TrimRight(cfg.BaseURL, "/"),
apiKey: cfg.APIKey,
model: cfg.Model,
reasoningKind: strings.TrimSpace(cfg.ReasoningKind),
client: &http.Client{
Transport: http.DefaultTransport,
Timeout: defaultHTTPTimeout,
},
log: discardLogger("runtime/model/openai"),
}
for _, o := range opts {
o(a)
}
return a
}
// Name returns the adapter identifier.
func (a *OpenAIChatAdapter) Name() string { return a.name }
// Chat performs a non-streaming completion.
func (a *OpenAIChatAdapter) Chat(ctx context.Context, req Request, opts Options) (Response, error) {
start := time.Now()
if a.baseURL == "" {
return Response{}, ErrUpstream
}
ctx, cancel := withCallTimeout(ctx, opts)
defer cancel()
model, err := a.resolveModel(req)
if err != nil {
return Response{}, err
}
req.Model = model
body, err := a.buildRequest(req, false)
if err != nil {
return Response{}, err
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, a.endpoint(), bytes.NewReader(body))
if err != nil {
return Response{}, fmt.Errorf("model: %w: build request: %v", ErrUpstream, err)
}
resp, err := a.doRequest(httpReq, false)
if err != nil {
return Response{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return Response{}, a.upstreamError(resp)
}
var wire openAIChatResponse
if err := json.NewDecoder(resp.Body).Decode(&wire); err != nil {
return Response{}, fmt.Errorf("model: %w: decode response: %v", ErrUpstream, err)
}
out := responseFromWire(&wire)
a.record(req, start, out.TotalTokens)
return out, nil
}
// ChatStream streams structured events via onEvent and returns the aggregated
// response with usage when the provider reports it in the final chunk.
func (a *OpenAIChatAdapter) ChatStream(ctx context.Context, req Request, onEvent func(StreamEvent)) (Response, error) {
start := time.Now()
if a.baseURL == "" {
return Response{}, ErrUpstream
}
model, err := a.resolveModel(req)
if err != nil {
return Response{}, err
}
req.Model = model
body, err := a.buildRequest(req, true)
if err != nil {
return Response{}, err
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, a.endpoint(), bytes.NewReader(body))
if err != nil {
return Response{}, fmt.Errorf("model: %w: build request: %v", ErrUpstream, err)
}
resp, err := a.doRequest(httpReq, true)
if err != nil {
return Response{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return Response{}, a.upstreamError(resp)
}
out, err := scanSSE(resp.Body, onEvent)
if err != nil {
return Response{}, err
}
out.Model = model
a.record(req, start, out.TotalTokens)
return out, nil
}
// withCallTimeout wraps ctx with opts.Timeout when it is positive.
func withCallTimeout(parent context.Context, opts Options) (context.Context, context.CancelFunc) {
if opts.Timeout > 0 {
return context.WithTimeout(parent, opts.Timeout)
}
return parent, func() {}
}
// resolveModel returns the effective model name.
func (a *OpenAIChatAdapter) resolveModel(req Request) (string, error) {
if req.Model != "" {
return req.Model, nil
}
if a.model != "" {
return a.model, nil
}
return "", ErrNoModel
}
// endpoint returns the full /chat/completions URL.
func (a *OpenAIChatAdapter) endpoint() string {
return a.baseURL + "/chat/completions"
}
func (a *OpenAIChatAdapter) doRequest(httpReq *http.Request, stream bool) (*http.Response, error) {
if a.apiKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+a.apiKey)
}
httpReq.Header.Set("Content-Type", "application/json")
if stream {
httpReq.Header.Set("Accept", "text/event-stream")
} else {
httpReq.Header.Set("Accept", "application/json")
}
resp, err := a.client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("model: %w: %v", ErrUpstream, err)
}
return resp, nil
}
func (a *OpenAIChatAdapter) upstreamError(resp *http.Response) error {
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, errBodyCap))
return fmt.Errorf("model: %w: status %d: %s", ErrUpstream, resp.StatusCode, strings.TrimSpace(string(snippet)))
}
func (a *OpenAIChatAdapter) record(req Request, start time.Time, tokens int) {
if a.recorder == nil {
return
}
a.recorder(CallRecord{
Model: req.Model,
Purpose: req.Purpose,
Tokens: tokens,
TookMs: time.Since(start).Milliseconds(),
})
}