- openai.go 216(适配器+Chat/ChatStream+共享助手); openai_encode.go 202(请求wire+build*); openai_response.go 79(响应wire+responseFromWire); openai_stream.go 174(scanSSE) - 字节级比对 body identical; 导出API不变(go doc diff 空); gofmt 干净 - 纯净基线(HEAD+本改)全仓 go build/vet/test 绿 + win/darwin 交叉编译绿 - 遗留: scanSSE 138行(>80, 拆分前既有,零逻辑改动未动), 另开步骤重构
217 lines
6.2 KiB
Go
217 lines
6.2 KiB
Go
package model
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"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
|
|
}
|
|
|
|
// 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 }
|
|
}
|
|
|
|
// NewOpenAIChat builds an adapter from a supplier config. baseURL is trimmed of
|
|
// any trailing slash before the /chat/completions path is appended.
|
|
func NewOpenAIChat(cfg config.LLMConfig, opts ...OpenAIOption) *OpenAIChatAdapter {
|
|
a := &OpenAIChatAdapter{
|
|
name: "openai",
|
|
baseURL: strings.TrimRight(cfg.BaseURL, "/"),
|
|
apiKey: cfg.APIKey,
|
|
model: cfg.Model,
|
|
client: &http.Client{
|
|
Transport: http.DefaultTransport,
|
|
Timeout: defaultHTTPTimeout,
|
|
},
|
|
}
|
|
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(),
|
|
})
|
|
}
|