Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ cq proxy status --port 19280
cq proxy install # Install the user launch agent
cq proxy uninstall # Remove the user launch agent
cq proxy restart # Restart the user launch agent
cq proxy pin # Show the pinned Claude account, if any
cq proxy pin <email-or-account-uuid>
cq proxy pin --clear # Clear the pinned Claude account
```

The proxy config is stored at `$XDG_CONFIG_HOME/cq/proxy.json`, or `~/.config/cq/proxy.json` when `XDG_CONFIG_HOME` is not set. If it does not exist, `cq proxy start` creates it with a random local token.
Expand All @@ -74,6 +77,7 @@ Important `proxy.json` fields:
| `claude_upstream` | `https://api.anthropic.com` | Anthropic API upstream. |
| `codex_upstream` | `https://chatgpt.com/backend-api/codex` | Codex backend upstream. |
| `local_token` | generated | Required bearer token for local proxy requests. |
| `pinned_claude_account` | unset | Optional Claude account email or UUID to force proxy selection. |
| `headroom` | `false` | Enables the headroom compression bridge when true. |
| `headroom_mode` | `cache` | Compression strategy when set; valid values are `cache` and `token`. |

Expand All @@ -85,7 +89,8 @@ Important `proxy.json` fields:
cq models refresh # Refresh registry data and publish caches
cq models list # List active registry models
cq models list --json # JSON model list
cq models list --provider codex # Filter by provider: codex or anthropic
cq models list --provider codex # Filter by provider
cq models list --provider anthropic

cq models overlay add --provider codex --id gpt-5.5 --clone-from gpt-5.4
cq models overlay remove --provider codex --id gpt-5.5
Expand All @@ -100,7 +105,7 @@ A registry refresh publishes provider-specific caches where supported:
- Claude Code model capabilities: `$CLAUDE_CONFIG_DIR/cache/model-capabilities.json`, or `~/.claude/cache/model-capabilities.json`.
- Claude Code picker options: `additionalModelOptionsCache` in `~/.claude.json`.

Claude Code still needs `ANTHROPIC_BASE_URL` pointed at the running proxy for runtime API traffic. The `/model` picker is populated from Claude Code config/cache files, so `cq models refresh` and the proxy publish registry-backed picker entries there.
Claude Code still needs `ANTHROPIC_BASE_URL` pointed at the running proxy for runtime API traffic. The `/model` picker is populated from Claude Code config/cache files, so `cq models refresh` and the proxy publish registry-backed picker entries there. The proxy also re-publishes picker entries automatically when it detects drift.

## Background Agent

Expand All @@ -122,7 +127,7 @@ For each provider, cq displays remaining quota as a percentage bar, pace indicat

| Environment variable | Default | Description |
|----------------------|---------|-------------|
| `CQ_TTL` | `30s` | Quota cache duration, e.g. `1m`, `5m`. |
| `CQ_TTL` | `30` | Quota cache duration in seconds, e.g. `60`, `300`. |
| `XDG_CONFIG_HOME` | `~/.config` | Base directory for cq config files. |
| `XDG_CACHE_HOME` | platform cache dir | Base directory for cq quota cache files. |
| `CLAUDE_CONFIG_DIR` | `~/.claude` | Claude Code config directory for model capability cache publication. |
Expand All @@ -138,6 +143,8 @@ For each provider, cq displays remaining quota as a percentage bar, pace indicat
| `~/.claude/.credentials.json` | Claude credentials read/written for account management. |
| `~/.claude.json` | Claude Code global config; cq writes managed model picker entries. |
| `~/.codex/models_cache.json` | Codex model cache populated by registry refresh. |
| `~/Library/Logs/cq/proxy.log` | macOS launch agent log for the proxy service. |
| `~/Library/Logs/cq/refresh.log` | macOS launch agent log for quota refresh. |

## Licence

Expand Down
133 changes: 133 additions & 0 deletions internal/proxy/codex_compact.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package proxy

import (
"bytes"
"fmt"
"io"
"net/http"
"os"
)

// handleCodexCompactResponsesRoute handles POST /v1/responses/compact.
func (s *Server) handleCodexCompactResponsesRoute(w http.ResponseWriter, r *http.Request) {
if isWebSocketUpgrade(r) {
rejectCodexCompactWebSocket(w, codexCompactResponsesPath)
return
}
s.handleNativeCodexCompact(w, r, codexCompactResponsesPath)
}

// handleCodexCompactResponsesGetRoute handles GET /v1/responses/compact.
func (s *Server) handleCodexCompactResponsesGetRoute(w http.ResponseWriter, r *http.Request) {
handleCodexCompactGet(w, r, codexCompactResponsesPath)
}

// handleLegacyCodexCompactResponsesRoute handles POST /responses/compact.
func (s *Server) handleLegacyCodexCompactResponsesRoute(w http.ResponseWriter, r *http.Request) {
if isWebSocketUpgrade(r) {
rejectCodexCompactWebSocket(w, legacyCodexCompactResponsesPath)
return
}
s.handleNativeCodexCompact(w, r, legacyCodexCompactResponsesPath)
}

// handleLegacyCodexCompactResponsesGetRoute handles GET /responses/compact.
func (s *Server) handleLegacyCodexCompactResponsesGetRoute(w http.ResponseWriter, r *http.Request) {
handleCodexCompactGet(w, r, legacyCodexCompactResponsesPath)
}

func handleCodexCompactGet(w http.ResponseWriter, r *http.Request, requestPath string) {
if isWebSocketUpgrade(r) {
rejectCodexCompactWebSocket(w, requestPath)
return
}
w.Header().Set("Allow", http.MethodPost)
writeError(w, http.StatusMethodNotAllowed, "invalid_request_error", fmt.Sprintf("%s only supports POST", requestPath))
}

func rejectCodexCompactWebSocket(w http.ResponseWriter, requestPath string) {
writeError(w, http.StatusBadRequest, "invalid_request_error",
fmt.Sprintf("websocket transport is not supported on %s; use %s", requestPath, codexAppServerPath))
}

// handleNativeCodexCompact forwards a compact request to the upstream
// /responses/compact endpoint using CodexTransport for auth injection.
// No headroom compression is applied — compact requests already represent
// a summarisation boundary; compressing them further is counterproductive.
func (s *Server) handleNativeCodexCompact(w http.ResponseWriter, r *http.Request, requestPath string) {
if s.CodexTransport == nil {
writeError(w, http.StatusServiceUnavailable, "api_error", "no codex accounts configured")
return
}

// Buffer request body.
body, err := io.ReadAll(io.LimitReader(r.Body, maxRequestBody+1))
r.Body.Close()
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request_error", "failed to read request body")
return
}
if len(body) > maxRequestBody {
writeError(w, http.StatusRequestEntityTooLarge, "invalid_request_error", "request body exceeds 10 MiB")
return
}

model := extractModel(body)
fmt.Fprintf(os.Stderr, "cq: route POST %s model=%q provider=codex (native compact)\n", requestPath, model)

// Build upstream request targeting /responses/compact (no headroom applied).
upstreamURL := s.Config.CodexUpstream + "/responses/compact"
upReq, err := http.NewRequestWithContext(r.Context(), http.MethodPost, upstreamURL, bytes.NewReader(body))
if err != nil {
writeError(w, http.StatusInternalServerError, "api_error", fmt.Sprintf("create upstream request: %v", err))
return
}
upReq.ContentLength = int64(len(body))
upReq.GetBody = func() (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(body)), nil
}

// Forward all original headers; transport will override auth.
for key, vals := range r.Header {
for _, v := range vals {
upReq.Header.Add(key, v)
}
}
if upReq.Header.Get("Content-Type") == "" {
upReq.Header.Set("Content-Type", "application/json")
}

// Transport handles auth injection and account rotation.
resp, err := s.CodexTransport.RoundTrip(upReq)
if err != nil {
writeError(w, http.StatusBadGateway, "api_error", fmt.Sprintf("codex upstream error: %v", err))
return
}
defer resp.Body.Close()

fmt.Fprintf(os.Stderr, "cq: proxy POST %s → %d (codex native compact)\n", upstreamURL, resp.StatusCode)

// Forward response headers, status, and body.
for key, vals := range resp.Header {
for _, v := range vals {
w.Header().Add(key, v)
}
}
w.WriteHeader(resp.StatusCode)

if f, ok := w.(http.Flusher); ok {
buf := make([]byte, 4096)
for {
n, readErr := resp.Body.Read(buf)
if n > 0 {
w.Write(buf[:n])
f.Flush()
}
if readErr != nil {
break
}
}
} else {
io.Copy(w, resp.Body)
}
}
Loading
Loading