mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-03 14:39:26 +08:00
feat(executor): implement agent-scoped cache key isolation and enhance replay handling
- Introduced agent-specific cache key isolation to ensure distinct prompt caching across agents. - Improved reasoning replay handling for Claude models, including cumulative tool turn restoration and validation. - Enhanced HTTP and WebSocket session consistency during execution scope and cache replay synchronization. - Added new unit tests and extended coverage for prompt caching, agent isolation, and replay cache functionality. Closes: #4352
This commit is contained in:
@@ -275,6 +275,10 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut
|
||||
body = ensureImageGenerationTool(body, baseModel, auth, opts.Headers)
|
||||
}
|
||||
body = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "codex websockets executor", body)
|
||||
body, replayScope, errReplay := applyCodexReasoningReplayCacheRequired(ctx, from, req, opts, body)
|
||||
if errReplay != nil {
|
||||
return resp, errReplay
|
||||
}
|
||||
|
||||
httpURL := strings.TrimSuffix(baseURL, "/") + "/responses"
|
||||
wsURL, err := buildCodexResponsesWebsocketURL(httpURL)
|
||||
@@ -282,7 +286,7 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut
|
||||
return resp, err
|
||||
}
|
||||
|
||||
body, wsHeaders, errPromptCache := applyCodexPromptCacheHeadersWithContext(ctx, from, req, body)
|
||||
body, wsHeaders, errPromptCache := applyCodexPromptCacheHeadersWithContext(ctx, from, req, body, opts.Headers)
|
||||
if errPromptCache != nil {
|
||||
return resp, errPromptCache
|
||||
}
|
||||
@@ -405,6 +409,8 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut
|
||||
}
|
||||
}
|
||||
|
||||
outputItemsByIndex := make(map[int64][]byte)
|
||||
var outputItemsFallback [][]byte
|
||||
for {
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return resp, ctx.Err()
|
||||
@@ -439,13 +445,27 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut
|
||||
if sess != nil {
|
||||
e.invalidateUpstreamConn(sess, conn, "upstream_error", wsErr)
|
||||
}
|
||||
if errClearReplay := clearCodexReasoningReplayOnWebsocketError(ctx, replayScope, payload); errClearReplay != nil {
|
||||
return resp, errClearReplay
|
||||
}
|
||||
helps.RecordAPIWebsocketError(ctx, e.cfg, "upstream_error", wsErr)
|
||||
return resp, wsErr
|
||||
}
|
||||
if streamErr, terminalBody, ok := codexTerminalFailureErr(payload); ok {
|
||||
if errClearReplay := clearCodexReasoningReplayOnInvalidSignature(ctx, replayScope, streamErr.StatusCode(), terminalBody); errClearReplay != nil {
|
||||
return resp, errClearReplay
|
||||
}
|
||||
return resp, streamErr
|
||||
}
|
||||
|
||||
payload = normalizeCodexWebsocketCompletion(payload)
|
||||
eventType := gjson.GetBytes(payload, "type").String()
|
||||
if eventType == "response.completed" {
|
||||
switch eventType {
|
||||
case "response.output_item.done":
|
||||
collectCodexOutputItemDone(payload, outputItemsByIndex, &outputItemsFallback)
|
||||
case "response.completed":
|
||||
payload = patchCodexCompletedOutput(payload, outputItemsByIndex, outputItemsFallback)
|
||||
cacheCodexReasoningReplayFromCompleted(replayScope, payload)
|
||||
if detail, ok := helps.ParseCodexUsage(payload); ok {
|
||||
reporter.Publish(ctx, detail)
|
||||
}
|
||||
@@ -479,11 +499,12 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
|
||||
from := opts.SourceFormat
|
||||
responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts)
|
||||
to := sdktranslator.FromString("codex")
|
||||
body := req.Payload
|
||||
userPayload := req.Payload
|
||||
originalPayloadSource := req.Payload
|
||||
if len(opts.OriginalRequest) > 0 {
|
||||
userPayload = opts.OriginalRequest
|
||||
originalPayloadSource = opts.OriginalRequest
|
||||
}
|
||||
originalPayload := originalPayloadSource
|
||||
originalTranslated, body := translateCodexRequestPair(from, to, baseModel, originalPayload, req.Payload, true)
|
||||
|
||||
body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())
|
||||
if err != nil {
|
||||
@@ -492,13 +513,17 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
|
||||
|
||||
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
|
||||
requestPath := helps.PayloadRequestPath(opts)
|
||||
body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, body, requestedModel, requestPath, opts.Headers)
|
||||
body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers)
|
||||
body, _ = sjson.SetBytes(body, "model", baseModel)
|
||||
body = normalizeCodexInstructions(body)
|
||||
if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff {
|
||||
body = ensureImageGenerationTool(body, baseModel, auth, opts.Headers)
|
||||
}
|
||||
body = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "codex websockets executor", body)
|
||||
body, replayScope, errReplay := applyCodexReasoningReplayCacheRequired(ctx, from, req, opts, body)
|
||||
if errReplay != nil {
|
||||
return nil, errReplay
|
||||
}
|
||||
|
||||
httpURL := strings.TrimSuffix(baseURL, "/") + "/responses"
|
||||
wsURL, err := buildCodexResponsesWebsocketURL(httpURL)
|
||||
@@ -506,13 +531,13 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, wsHeaders, errPromptCache := applyCodexPromptCacheHeadersWithContext(ctx, from, req, body)
|
||||
body, wsHeaders, errPromptCache := applyCodexPromptCacheHeadersWithContext(ctx, from, req, body, opts.Headers)
|
||||
if errPromptCache != nil {
|
||||
return nil, errPromptCache
|
||||
}
|
||||
clientBody := body
|
||||
var identityState codexIdentityConfuseState
|
||||
upstreamBody, identityState := applyCodexIdentityConfuseBody(e.cfg, auth, userPayload, body)
|
||||
upstreamBody, identityState := applyCodexIdentityConfuseBody(e.cfg, auth, originalPayloadSource, body)
|
||||
reporter.SetTranslatedReasoningEffort(clientBody, to.String())
|
||||
wsHeaders = applyCodexWebsocketHeaders(ctx, wsHeaders, auth, apiKey, e.cfg)
|
||||
applyModelHeaderOverrides(wsHeaders, baseModel)
|
||||
@@ -659,6 +684,8 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
|
||||
}
|
||||
|
||||
var param any
|
||||
outputItemsByIndex := make(map[int64][]byte)
|
||||
var outputItemsFallback [][]byte
|
||||
for {
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
terminateReason = "context_done"
|
||||
@@ -709,24 +736,54 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
|
||||
if wsErr, ok := parseCodexWebsocketError(payload); ok {
|
||||
terminateReason = "upstream_error"
|
||||
terminateErr = wsErr
|
||||
helps.RecordAPIWebsocketError(ctx, e.cfg, "upstream_error", wsErr)
|
||||
reporter.PublishFailure(ctx, wsErr)
|
||||
if sess != nil {
|
||||
e.invalidateUpstreamConn(sess, conn, "upstream_error", wsErr)
|
||||
}
|
||||
if errClearReplay := clearCodexReasoningReplayOnWebsocketError(ctx, replayScope, payload); errClearReplay != nil {
|
||||
terminateErr = errClearReplay
|
||||
helps.RecordAPIWebsocketError(ctx, e.cfg, "replay_clear_error", errClearReplay)
|
||||
reporter.PublishFailure(ctx, errClearReplay)
|
||||
_ = send(cliproxyexecutor.StreamChunk{Err: errClearReplay})
|
||||
return
|
||||
}
|
||||
helps.RecordAPIWebsocketError(ctx, e.cfg, "upstream_error", wsErr)
|
||||
reporter.PublishFailure(ctx, wsErr)
|
||||
_ = send(cliproxyexecutor.StreamChunk{Err: wsErr})
|
||||
return
|
||||
}
|
||||
if streamErr, terminalBody, ok := codexTerminalFailureErr(payload); ok {
|
||||
terminateReason = "upstream_error"
|
||||
terminateErr = streamErr
|
||||
if errClearReplay := clearCodexReasoningReplayOnInvalidSignature(ctx, replayScope, streamErr.StatusCode(), terminalBody); errClearReplay != nil {
|
||||
terminateErr = errClearReplay
|
||||
helps.RecordAPIWebsocketError(ctx, e.cfg, "replay_clear_error", errClearReplay)
|
||||
reporter.PublishFailure(ctx, errClearReplay)
|
||||
_ = send(cliproxyexecutor.StreamChunk{Err: errClearReplay})
|
||||
return
|
||||
}
|
||||
helps.RecordAPIWebsocketError(ctx, e.cfg, "upstream_error", streamErr)
|
||||
reporter.PublishFailure(ctx, streamErr)
|
||||
_ = send(cliproxyexecutor.StreamChunk{Err: streamErr})
|
||||
return
|
||||
}
|
||||
|
||||
eventType := gjson.GetBytes(payload, "type").String()
|
||||
isTerminalEvent := eventType == "response.completed" || eventType == "response.done" || eventType == "error"
|
||||
if eventType == "response.output_item.done" {
|
||||
collectCodexOutputItemDone(payload, outputItemsByIndex, &outputItemsFallback)
|
||||
}
|
||||
completedPayload := payload
|
||||
if eventType == "response.completed" || eventType == "response.done" {
|
||||
completedPayload = normalizeCodexWebsocketCompletion(completedPayload)
|
||||
completedPayload = patchCodexCompletedOutput(completedPayload, outputItemsByIndex, outputItemsFallback)
|
||||
cacheCodexReasoningReplayFromCompleted(replayScope, completedPayload)
|
||||
if detail, ok := helps.ParseCodexUsage(completedPayload); ok {
|
||||
reporter.Publish(ctx, detail)
|
||||
}
|
||||
}
|
||||
|
||||
clientPayload := applyCodexIdentityExposeResponsePayload(payload, identityState)
|
||||
if cliproxyexecutor.DownstreamWebsocket(ctx) {
|
||||
if eventType == "response.completed" || eventType == "response.done" {
|
||||
if detail, ok := helps.ParseCodexUsage(payload); ok {
|
||||
reporter.Publish(ctx, detail)
|
||||
}
|
||||
}
|
||||
if !send(cliproxyexecutor.StreamChunk{Payload: clientPayload}) {
|
||||
terminateReason = "context_done"
|
||||
terminateErr = ctx.Err()
|
||||
@@ -739,16 +796,13 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
|
||||
}
|
||||
|
||||
payload = normalizeCodexWebsocketCompletion(payload)
|
||||
eventType = gjson.GetBytes(payload, "type").String()
|
||||
if eventType == "response.completed" || eventType == "response.done" {
|
||||
if detail, ok := helps.ParseCodexUsage(payload); ok {
|
||||
reporter.Publish(ctx, detail)
|
||||
}
|
||||
payload = completedPayload
|
||||
}
|
||||
|
||||
eventType = gjson.GetBytes(payload, "type").String()
|
||||
clientPayload = applyCodexIdentityExposeResponsePayload(payload, identityState)
|
||||
line := encodeCodexWebsocketAsSSE(clientPayload)
|
||||
chunks := sdktranslator.TranslateStream(ctx, to, responseFormat, req.Model, clientBody, clientBody, line, ¶m)
|
||||
chunks := sdktranslator.TranslateStream(ctx, to, responseFormat, req.Model, originalPayload, clientBody, line, ¶m)
|
||||
for i := range chunks {
|
||||
if !send(cliproxyexecutor.StreamChunk{Payload: chunks[i]}) {
|
||||
terminateReason = "context_done"
|
||||
@@ -941,15 +995,23 @@ func applyCodexPromptCacheHeaders(from sdktranslator.Format, req cliproxyexecuto
|
||||
return body, headers
|
||||
}
|
||||
|
||||
func applyCodexPromptCacheHeadersWithContext(ctx context.Context, from sdktranslator.Format, req cliproxyexecutor.Request, rawJSON []byte) ([]byte, http.Header, error) {
|
||||
func applyCodexPromptCacheHeadersWithContext(ctx context.Context, from sdktranslator.Format, req cliproxyexecutor.Request, rawJSON []byte, headerSets ...http.Header) ([]byte, http.Header, error) {
|
||||
headers := http.Header{}
|
||||
if len(rawJSON) == 0 {
|
||||
return rawJSON, headers, nil
|
||||
}
|
||||
|
||||
var requestHeaders http.Header
|
||||
if len(headerSets) > 0 {
|
||||
requestHeaders = headerSets[0]
|
||||
}
|
||||
var cache helps.CodexCache
|
||||
if sourceFormatEqual(from, sdktranslator.FormatClaude) {
|
||||
cached, ok, errCache := helps.ClaudeCodePromptCache(ctx, req.Model, req.Payload, nil)
|
||||
modelName := strings.TrimSpace(gjson.GetBytes(rawJSON, "model").String())
|
||||
if modelName == "" {
|
||||
modelName = thinking.ParseSuffix(req.Model).ModelName
|
||||
}
|
||||
cached, ok, errCache := helps.ClaudeCodePromptCache(ctx, modelName, req.Payload, requestHeaders)
|
||||
if errCache != nil {
|
||||
return nil, nil, errCache
|
||||
}
|
||||
@@ -1271,6 +1333,17 @@ func parseCodexWebsocketError(payload []byte) (error, bool) {
|
||||
}, true
|
||||
}
|
||||
|
||||
func clearCodexReasoningReplayOnWebsocketError(ctx context.Context, scope codexReasoningReplayScope, payload []byte) error {
|
||||
status := int(gjson.GetBytes(payload, "status").Int())
|
||||
if status == 0 {
|
||||
status = int(gjson.GetBytes(payload, "status_code").Int())
|
||||
}
|
||||
if status <= 0 {
|
||||
return nil
|
||||
}
|
||||
return clearCodexReasoningReplayOnInvalidSignature(ctx, scope, status, buildCodexWebsocketErrorPayload(payload, status))
|
||||
}
|
||||
|
||||
func buildCodexWebsocketErrorPayload(payload []byte, status int) []byte {
|
||||
out := []byte(`{}`)
|
||||
out, _ = sjson.SetBytes(out, "status", status)
|
||||
|
||||
Reference in New Issue
Block a user