mirror of
https://github.com/chenhg5/cc-connect.git
synced 2026-07-07 07:10:46 +08:00
* feat(core): add optional reply footer metadata * feat(config): enable reply footer by default * feat(codex): use runtime footer metadata from app-server * feat(codex): resolve exec footer metadata at runtime * feat(core): style reply footer and prefer context left * feat(codex): use real runtime context for reply footer * feat(core): use english left footer text * fix(codex): use current context tokens for footer left * fix(codex): match cli context left calculation * test(codex): adapt footer tests to session base url arg
292 lines
7.6 KiB
Go
292 lines
7.6 KiB
Go
package codex
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/chenhg5/cc-connect/core"
|
|
)
|
|
|
|
const codexRolloutTailBytes int64 = 1 << 20
|
|
const codexContextBaselineTokens = 12000
|
|
|
|
type codexTokenUsage struct {
|
|
TotalTokens int `json:"totalTokens"`
|
|
InputTokens int `json:"inputTokens"`
|
|
CachedInputTokens int `json:"cachedInputTokens"`
|
|
OutputTokens int `json:"outputTokens"`
|
|
ReasoningOutputTokens int `json:"reasoningOutputTokens"`
|
|
}
|
|
|
|
type codexSnakeTokenUsage struct {
|
|
TotalTokens int `json:"total_tokens"`
|
|
InputTokens int `json:"input_tokens"`
|
|
CachedInputTokens int `json:"cached_input_tokens"`
|
|
OutputTokens int `json:"output_tokens"`
|
|
ReasoningOutputTokens int `json:"reasoning_output_tokens"`
|
|
}
|
|
|
|
type appServerThreadTokenUsageNotification struct {
|
|
ThreadID string `json:"threadId"`
|
|
TurnID string `json:"turnId"`
|
|
TokenUsage struct {
|
|
Total codexTokenUsage `json:"total"`
|
|
Last codexTokenUsage `json:"last"`
|
|
ModelContextWindow int `json:"modelContextWindow"`
|
|
} `json:"tokenUsage"`
|
|
}
|
|
|
|
func mapAppServerTokenUsage(notif appServerThreadTokenUsageNotification) *core.ContextUsage {
|
|
return contextUsageFromCamel(notif.TokenUsage.Last, notif.TokenUsage.ModelContextWindow)
|
|
}
|
|
|
|
func contextUsageFromCamel(usage codexTokenUsage, contextWindow int) *core.ContextUsage {
|
|
return contextUsageFromParts(
|
|
currentContextTokens(usage.TotalTokens, usage.InputTokens, usage.OutputTokens),
|
|
usage.TotalTokens,
|
|
usage.InputTokens,
|
|
usage.CachedInputTokens,
|
|
usage.OutputTokens,
|
|
usage.ReasoningOutputTokens,
|
|
contextWindow,
|
|
)
|
|
}
|
|
|
|
func contextUsageFromSnake(usage codexSnakeTokenUsage, contextWindow int) *core.ContextUsage {
|
|
return contextUsageFromParts(
|
|
currentContextTokens(usage.TotalTokens, usage.InputTokens, usage.OutputTokens),
|
|
usage.TotalTokens,
|
|
usage.InputTokens,
|
|
usage.CachedInputTokens,
|
|
usage.OutputTokens,
|
|
usage.ReasoningOutputTokens,
|
|
contextWindow,
|
|
)
|
|
}
|
|
|
|
func currentContextTokens(totalTokens, inputTokens, outputTokens int) int {
|
|
if totalTokens > 0 {
|
|
return totalTokens
|
|
}
|
|
if inputTokens > 0 || outputTokens > 0 {
|
|
return inputTokens + outputTokens
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func contextUsageFromParts(usedTokens, totalTokens, inputTokens, cachedInputTokens, outputTokens, reasoningOutputTokens, contextWindow int) *core.ContextUsage {
|
|
if totalTokens <= 0 && inputTokens <= 0 && outputTokens <= 0 {
|
|
return nil
|
|
}
|
|
if contextWindow <= 0 {
|
|
return nil
|
|
}
|
|
return &core.ContextUsage{
|
|
UsedTokens: usedTokens,
|
|
BaselineTokens: codexContextBaselineTokens,
|
|
TotalTokens: totalTokens,
|
|
InputTokens: inputTokens,
|
|
CachedInputTokens: cachedInputTokens,
|
|
OutputTokens: outputTokens,
|
|
ReasoningOutputTokens: reasoningOutputTokens,
|
|
ContextWindow: contextWindow,
|
|
}
|
|
}
|
|
|
|
func cloneContextUsage(usage *core.ContextUsage) *core.ContextUsage {
|
|
if usage == nil {
|
|
return nil
|
|
}
|
|
cloned := *usage
|
|
return &cloned
|
|
}
|
|
|
|
func loadContextUsageFromRollout(extraEnv []string, sessionID, cachedPath string) (*core.ContextUsage, string, error) {
|
|
path := strings.TrimSpace(cachedPath)
|
|
if path != "" {
|
|
usage, err := readContextUsageFromRollout(path)
|
|
if err == nil && usage != nil {
|
|
return usage, path, nil
|
|
}
|
|
}
|
|
|
|
codexHome, err := resolveCodexHome(extraEnv)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
path = findSessionFileInCodexHome(codexHome, sessionID)
|
|
if path == "" {
|
|
return nil, "", fmt.Errorf("session file not found for %s", sessionID)
|
|
}
|
|
usage, err := readContextUsageFromRollout(path)
|
|
if err != nil {
|
|
return nil, path, err
|
|
}
|
|
if usage == nil {
|
|
return nil, path, fmt.Errorf("context usage not found in rollout")
|
|
}
|
|
return usage, path, nil
|
|
}
|
|
|
|
func resolveCodexHome(extraEnv []string) (string, error) {
|
|
if value := getenvFromList(extraEnv, "CODEX_HOME"); value != "" {
|
|
return strings.TrimSpace(value), nil
|
|
}
|
|
if value := strings.TrimSpace(os.Getenv("CODEX_HOME")); value != "" {
|
|
return value, nil
|
|
}
|
|
homeDir, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve home directory: %w", err)
|
|
}
|
|
return filepath.Join(homeDir, ".codex"), nil
|
|
}
|
|
|
|
func getenvFromList(env []string, key string) string {
|
|
prefix := key + "="
|
|
for i := len(env) - 1; i >= 0; i-- {
|
|
entry := env[i]
|
|
if strings.HasPrefix(entry, prefix) {
|
|
return strings.TrimSpace(strings.TrimPrefix(entry, prefix))
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func findSessionFileInCodexHome(codexHome, sessionID string) string {
|
|
if strings.TrimSpace(codexHome) == "" || strings.TrimSpace(sessionID) == "" {
|
|
return ""
|
|
}
|
|
|
|
pattern := filepath.Join(codexHome, "sessions", "*", "*", "*", "rollout-*"+sessionID+".jsonl")
|
|
if matches, _ := filepath.Glob(pattern); len(matches) > 0 {
|
|
sort.Strings(matches)
|
|
return matches[len(matches)-1]
|
|
}
|
|
|
|
sessionsDir := filepath.Join(codexHome, "sessions")
|
|
var found string
|
|
_ = filepath.Walk(sessionsDir, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil || info == nil || info.IsDir() || found != "" {
|
|
return nil
|
|
}
|
|
if strings.Contains(filepath.Base(path), sessionID) {
|
|
found = path
|
|
}
|
|
return nil
|
|
})
|
|
return found
|
|
}
|
|
|
|
func readContextUsageFromRollout(path string) (*core.ContextUsage, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
|
|
if usage, err := readContextUsageFromRolloutTail(f); err != nil {
|
|
return nil, err
|
|
} else if usage != nil {
|
|
return usage, nil
|
|
}
|
|
|
|
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
|
return nil, err
|
|
}
|
|
return scanContextUsageFromRollout(f)
|
|
}
|
|
|
|
func readContextUsageFromRolloutTail(f *os.File) (*core.ContextUsage, error) {
|
|
info, err := f.Stat()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if info.Size() <= 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
start := int64(0)
|
|
if info.Size() > codexRolloutTailBytes {
|
|
start = info.Size() - codexRolloutTailBytes
|
|
}
|
|
buf := make([]byte, int(info.Size()-start))
|
|
n, err := f.ReadAt(buf, start)
|
|
if err != nil && err != io.EOF {
|
|
return nil, err
|
|
}
|
|
buf = buf[:n]
|
|
if start > 0 {
|
|
if idx := bytes.IndexByte(buf, '\n'); idx >= 0 {
|
|
buf = buf[idx+1:]
|
|
}
|
|
}
|
|
return parseContextUsageFromRolloutBytes(buf), nil
|
|
}
|
|
|
|
func parseContextUsageFromRolloutBytes(data []byte) *core.ContextUsage {
|
|
lines := bytes.Split(data, []byte{'\n'})
|
|
for i := len(lines) - 1; i >= 0; i-- {
|
|
line := bytes.TrimSpace(lines[i])
|
|
if len(line) == 0 {
|
|
continue
|
|
}
|
|
if usage := parseContextUsageFromRolloutLine(line); usage != nil {
|
|
return usage
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func scanContextUsageFromRollout(r io.Reader) (*core.ContextUsage, error) {
|
|
scanner := bufio.NewScanner(r)
|
|
scanner.Buffer(make([]byte, 64*1024), 2*1024*1024)
|
|
|
|
var last *core.ContextUsage
|
|
for scanner.Scan() {
|
|
if usage := parseContextUsageFromRolloutLine(scanner.Bytes()); usage != nil {
|
|
last = usage
|
|
}
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return last, nil
|
|
}
|
|
|
|
func parseContextUsageFromRolloutLine(line []byte) *core.ContextUsage {
|
|
var entry struct {
|
|
Type string `json:"type"`
|
|
Payload json.RawMessage `json:"payload"`
|
|
}
|
|
if err := json.Unmarshal(line, &entry); err != nil {
|
|
return nil
|
|
}
|
|
if entry.Type != "event_msg" {
|
|
return nil
|
|
}
|
|
|
|
var payload struct {
|
|
Type string `json:"type"`
|
|
Info *struct {
|
|
TotalTokenUsage codexSnakeTokenUsage `json:"total_token_usage"`
|
|
LastTokenUsage codexSnakeTokenUsage `json:"last_token_usage"`
|
|
ModelContextWindow int `json:"model_context_window"`
|
|
} `json:"info"`
|
|
}
|
|
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
|
return nil
|
|
}
|
|
if payload.Type != "token_count" || payload.Info == nil {
|
|
return nil
|
|
}
|
|
return contextUsageFromSnake(payload.Info.LastTokenUsage, payload.Info.ModelContextWindow)
|
|
}
|