- 重写工具调用解析:XML、DSML 未加引号键、fence 内跳过、参数外溢等形状,并按请求携带的 tools 过滤工具名
- 新增 GET /v1/responses/{id} 与 previous_response_id 有界状态缓存(进程内、TTL、store=false 不写入)
- /v1/chat/completions 支持 Idempotency-Key 幂等回放(1 MiB 上限、24h TTL、并发去重)
- 模型别名与 default_model 降级;错误体统一输出 param/code
- 补 e2e 修复场景与 docs/compat-audit.md 兼容性审计
411 lines
10 KiB
Go
411 lines
10 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Idempotency-Key semantics (Stripe / OpenAI conventions): a retried POST that
|
|
// carries the same key and body must not hit the upstream twice.
|
|
//
|
|
// - the key's scope is (API key, method+path, Idempotency-Key);
|
|
// - same key + different body → 400 idempotency_error;
|
|
// - same key, first request → executes, registered as in-flight;
|
|
// - same key while in-flight → 409 idempotency_error;
|
|
// - same key, completed → byte-for-byte replay with
|
|
// `idempotent-replayed: true`;
|
|
// - response larger than the per-entry cap, or a write that failed → the
|
|
// entry becomes unreplayable (409) or is dropped so the retry can re-run.
|
|
//
|
|
// The cache is in-process, bounded and TTL'd; it never touches disk.
|
|
const (
|
|
idemTTL = 24 * time.Hour
|
|
idemCapacity = 1024
|
|
idemMaxBody = 1 << 20 // 1 MiB per recorded response
|
|
idemMaxTotal = 64 << 20 // 64 MiB across all recorded responses
|
|
idemKeyMaxLen = 255 // header length cap
|
|
)
|
|
|
|
var idempotentPaths = map[string]bool{
|
|
"/v1/chat/completions": true,
|
|
"/v1/responses": true,
|
|
"/v1/messages": true,
|
|
}
|
|
|
|
type idemKind int
|
|
|
|
const (
|
|
idemFresh idemKind = iota
|
|
idemReplay
|
|
idemInProgress
|
|
idemConflict
|
|
idemUnreplayable
|
|
)
|
|
|
|
type idemRecorded struct {
|
|
status int
|
|
contentType string
|
|
body []byte
|
|
}
|
|
|
|
type idemEntry struct {
|
|
fp [32]byte
|
|
created time.Time
|
|
mu sync.Mutex
|
|
state idemState
|
|
rec idemRecorded
|
|
}
|
|
|
|
type idemState int
|
|
|
|
const (
|
|
idemStateInFlight idemState = iota
|
|
idemStateCompleted
|
|
idemStateUnreplayable
|
|
)
|
|
|
|
func (e *idemEntry) recordedSize() int {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
if e.state == idemStateCompleted {
|
|
return len(e.rec.body)
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (e *idemEntry) isCompleted() bool {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
return e.state == idemStateCompleted
|
|
}
|
|
|
|
type idemBeginResult struct {
|
|
kind idemKind
|
|
recorded idemRecorded
|
|
guard *idemGuard
|
|
}
|
|
|
|
type idempotencyStore struct {
|
|
mu sync.Mutex
|
|
entries map[string]*idemEntry
|
|
order []string
|
|
recordedBytes int
|
|
}
|
|
|
|
func newIdempotencyStore() *idempotencyStore {
|
|
return &idempotencyStore{entries: map[string]*idemEntry{}}
|
|
}
|
|
|
|
func (s *idempotencyStore) begin(scope, key string, fp [32]byte) idemBeginResult {
|
|
cacheKey := scope + "\x01" + key
|
|
|
|
s.mu.Lock()
|
|
s.evictExpiredLocked()
|
|
if e, ok := s.entries[cacheKey]; ok {
|
|
s.mu.Unlock()
|
|
if e.fp != fp {
|
|
return idemBeginResult{kind: idemConflict}
|
|
}
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
switch e.state {
|
|
case idemStateInFlight:
|
|
return idemBeginResult{kind: idemInProgress}
|
|
case idemStateCompleted:
|
|
rec := e.rec
|
|
rec.body = append([]byte(nil), e.rec.body...)
|
|
return idemBeginResult{kind: idemReplay, recorded: rec}
|
|
default:
|
|
return idemBeginResult{kind: idemUnreplayable}
|
|
}
|
|
}
|
|
e := &idemEntry{fp: fp, created: time.Now(), state: idemStateInFlight}
|
|
s.entries[cacheKey] = e
|
|
s.order = append(s.order, cacheKey)
|
|
s.evictOverflowLocked()
|
|
s.mu.Unlock()
|
|
|
|
return idemBeginResult{kind: idemFresh, guard: &idemGuard{store: s, cacheKey: cacheKey, entry: e}}
|
|
}
|
|
|
|
func (s *idempotencyStore) commit(cacheKey string, e *idemEntry, rec idemRecorded) {
|
|
rec.body = append([]byte(nil), rec.body...)
|
|
e.mu.Lock()
|
|
e.state = idemStateCompleted
|
|
e.rec = rec
|
|
e.mu.Unlock()
|
|
|
|
s.mu.Lock()
|
|
s.recordedBytes += len(rec.body)
|
|
s.enforceBudgetLocked(cacheKey)
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
func (s *idempotencyStore) markUnreplayable(cacheKey string, e *idemEntry) {
|
|
e.mu.Lock()
|
|
e.state = idemStateUnreplayable
|
|
e.rec = idemRecorded{}
|
|
e.mu.Unlock()
|
|
}
|
|
|
|
func (s *idempotencyStore) remove(cacheKey string, e *idemEntry) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if cur, ok := s.entries[cacheKey]; ok && cur == e {
|
|
delete(s.entries, cacheKey)
|
|
s.order = removeString(s.order, cacheKey)
|
|
s.recordedBytes -= e.recordedSize()
|
|
if s.recordedBytes < 0 {
|
|
s.recordedBytes = 0
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *idempotencyStore) evictExpiredLocked() {
|
|
now := time.Now()
|
|
for key, e := range s.entries {
|
|
if now.Sub(e.created) > idemTTL {
|
|
delete(s.entries, key)
|
|
s.order = removeString(s.order, key)
|
|
s.recordedBytes -= e.recordedSize()
|
|
}
|
|
}
|
|
if s.recordedBytes < 0 {
|
|
s.recordedBytes = 0
|
|
}
|
|
}
|
|
|
|
func (s *idempotencyStore) evictOverflowLocked() {
|
|
for len(s.entries) > idemCapacity {
|
|
if len(s.order) == 0 {
|
|
return
|
|
}
|
|
oldest := s.order[0]
|
|
s.order = s.order[1:]
|
|
if e, ok := s.entries[oldest]; ok {
|
|
delete(s.entries, oldest)
|
|
s.recordedBytes -= e.recordedSize()
|
|
}
|
|
}
|
|
if s.recordedBytes < 0 {
|
|
s.recordedBytes = 0
|
|
}
|
|
}
|
|
|
|
// enforceBudgetLocked drops the oldest completed entries until the recorded
|
|
// body budget fits, never touching the entry just committed.
|
|
func (s *idempotencyStore) enforceBudgetLocked(keep string) {
|
|
for s.recordedBytes > idemMaxTotal {
|
|
idx := -1
|
|
for i, key := range s.order {
|
|
if key == keep {
|
|
continue
|
|
}
|
|
if e, ok := s.entries[key]; ok && e.isCompleted() {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
if idx < 0 {
|
|
return
|
|
}
|
|
key := s.order[idx]
|
|
e := s.entries[key]
|
|
delete(s.entries, key)
|
|
s.order = append(s.order[:idx], s.order[idx+1:]...)
|
|
s.recordedBytes -= e.recordedSize()
|
|
}
|
|
if s.recordedBytes < 0 {
|
|
s.recordedBytes = 0
|
|
}
|
|
}
|
|
|
|
// idemGuard owns a fresh in-flight entry. finish commits the recorded response;
|
|
// release (deferred) removes the placeholders if finish never ran (panic or an
|
|
// early return), so a retry can genuinely re-run.
|
|
type idemGuard struct {
|
|
store *idempotencyStore
|
|
cacheKey string
|
|
entry *idemEntry
|
|
finished bool
|
|
}
|
|
|
|
func (g *idemGuard) finish(status int, contentType string, body []byte, overflow, writeErr bool) {
|
|
if g.finished {
|
|
return
|
|
}
|
|
g.finished = true
|
|
switch {
|
|
case overflow:
|
|
g.store.markUnreplayable(g.cacheKey, g.entry)
|
|
case writeErr:
|
|
g.store.remove(g.cacheKey, g.entry)
|
|
default:
|
|
g.store.commit(g.cacheKey, g.entry, idemRecorded{status: status, contentType: contentType, body: body})
|
|
}
|
|
}
|
|
|
|
func (g *idemGuard) release() {
|
|
if !g.finished {
|
|
g.finished = true
|
|
g.store.remove(g.cacheKey, g.entry)
|
|
}
|
|
}
|
|
|
|
func idemFingerprint(scope, key string, body []byte) [32]byte {
|
|
h := sha256.New()
|
|
_, _ = h.Write([]byte(scope))
|
|
_, _ = h.Write([]byte{0})
|
|
_, _ = h.Write([]byte(key))
|
|
_, _ = h.Write([]byte{0})
|
|
_, _ = h.Write(body)
|
|
var out [32]byte
|
|
copy(out[:], h.Sum(nil))
|
|
return out
|
|
}
|
|
|
|
// idempotency wraps POST endpoints with Idempotency-Key support. Requests
|
|
// without the header pass straight through.
|
|
func (s *Server) idempotency(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost || !idempotentPaths[r.URL.Path] {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
key := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
|
|
if key == "" {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
if len(key) > idemKeyMaxLen {
|
|
idemFail(w, r.URL.Path, http.StatusBadRequest, "Idempotency-Key must be at most 255 characters")
|
|
return
|
|
}
|
|
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, maxRequestBodyBytes+1))
|
|
if err != nil {
|
|
idemFail(w, r.URL.Path, http.StatusBadRequest, "failed to read request body")
|
|
return
|
|
}
|
|
_ = r.Body.Close()
|
|
if len(body) > maxRequestBodyBytes {
|
|
idemFail(w, r.URL.Path, http.StatusRequestEntityTooLarge, "request body too large")
|
|
return
|
|
}
|
|
r.Body = io.NopCloser(bytes.NewReader(body))
|
|
|
|
// A request that will fail auth must not occupy a key: the scope
|
|
// includes the API key, so only engage for authenticated callers.
|
|
token := extractKey(r)
|
|
s.cfg.RLock()
|
|
keys := append([]string(nil), s.cfg.Security.APIKeys...)
|
|
s.cfg.RUnlock()
|
|
if len(keys) > 0 && (token == "" || !matchesAny(keys, token)) {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
scope := token + "|" + r.Method + " " + r.URL.Path
|
|
fp := idemFingerprint(scope, key, body)
|
|
|
|
result := s.idem.begin(scope, key, fp)
|
|
switch result.kind {
|
|
case idemReplay:
|
|
rec := result.recorded
|
|
if rec.contentType != "" {
|
|
w.Header().Set("Content-Type", rec.contentType)
|
|
}
|
|
w.Header().Set("idempotent-replayed", "true")
|
|
if rec.status == 0 {
|
|
rec.status = http.StatusOK
|
|
}
|
|
w.WriteHeader(rec.status)
|
|
_, _ = w.Write(rec.body)
|
|
return
|
|
case idemInProgress:
|
|
idemFail(w, r.URL.Path, http.StatusConflict, "a request with this Idempotency-Key is still in progress; retry later or use a new key")
|
|
return
|
|
case idemConflict:
|
|
idemFail(w, r.URL.Path, http.StatusBadRequest, "Idempotency-Key was reused with a different request body; use a new key")
|
|
return
|
|
case idemUnreplayable:
|
|
idemFail(w, r.URL.Path, http.StatusConflict, "the response for this Idempotency-Key could not be cached for replay; use a new key")
|
|
return
|
|
}
|
|
|
|
guard := result.guard
|
|
defer guard.release()
|
|
rw := &recordingWriter{ResponseWriter: w}
|
|
next.ServeHTTP(rw, r)
|
|
guard.finish(rw.statusCode(), rw.contentType(), rw.buf, rw.overflow, rw.writeErr)
|
|
})
|
|
}
|
|
|
|
func idemFail(w http.ResponseWriter, path string, code int, msg string) {
|
|
if path == "/v1/messages" {
|
|
writeAnthropicError(w, code, msg, "invalid_request_error")
|
|
return
|
|
}
|
|
writeError(w, code, msg, "idempotency_error")
|
|
}
|
|
|
|
// recordingWriter tees the handler's response into a bounded buffer while
|
|
// forwarding it to the real writer.
|
|
type recordingWriter struct {
|
|
http.ResponseWriter
|
|
status int
|
|
wroteHdr bool
|
|
ct string
|
|
buf []byte
|
|
overflow bool
|
|
writeErr bool
|
|
}
|
|
|
|
func (rw *recordingWriter) WriteHeader(code int) {
|
|
if !rw.wroteHdr {
|
|
rw.wroteHdr = true
|
|
rw.status = code
|
|
rw.ct = rw.Header().Get("Content-Type")
|
|
}
|
|
rw.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
func (rw *recordingWriter) Write(b []byte) (int, error) {
|
|
if !rw.wroteHdr {
|
|
rw.WriteHeader(http.StatusOK)
|
|
}
|
|
n, err := rw.ResponseWriter.Write(b)
|
|
if err != nil {
|
|
rw.writeErr = true
|
|
}
|
|
if !rw.overflow {
|
|
if len(rw.buf)+len(b) > idemMaxBody {
|
|
rw.overflow = true
|
|
rw.buf = nil
|
|
} else {
|
|
rw.buf = append(rw.buf, b...)
|
|
}
|
|
}
|
|
return n, err
|
|
}
|
|
|
|
func (rw *recordingWriter) Flush() {
|
|
if f, ok := rw.ResponseWriter.(http.Flusher); ok {
|
|
f.Flush()
|
|
}
|
|
}
|
|
|
|
func (rw *recordingWriter) statusCode() int {
|
|
if rw.status == 0 {
|
|
return http.StatusOK
|
|
}
|
|
return rw.status
|
|
}
|
|
|
|
func (rw *recordingWriter) contentType() string { return rw.ct }
|