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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ cq proxy pin <email-or-account-uuid>
cq proxy pin --clear # Clear the pinned Claude account
```

Use `cq proxy pin --clear` to clear a pin. `clear` and `remove` are reserved words, not valid literal pin values.

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.

Important `proxy.json` fields:
Expand All @@ -88,9 +90,12 @@ Important `proxy.json` fields:
| `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. |
| `diagnostics_log` | unset | Optional JSONL routing diagnostics log path for advanced local debugging. |
| `headroom` | `false` | Enables the headroom compression bridge when true. |
| `headroom_mode` | `cache` | Compression strategy when set; valid values are `cache` and `token`. |

Routing diagnostics are disabled by default. To enable them, set `diagnostics_log` in `proxy.json` to a local file path and restart the proxy. The log is append-only JSONL containing redacted route metadata such as method, path, provider, route kind, status, latency, selected-account hint, failover flag, and safe error code. It is intended for advanced local debugging and UAT, and enabling it does not change routing policy.

## Model Registry

`cq models` manages the local model registry used by the proxy, Claude Code model caches, and Codex model cache integration.
Expand Down
55 changes: 43 additions & 12 deletions cmd/cq/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@ func runProxyPin(args []string) error {
return fmt.Errorf("load config: %w", err)
}

// cq proxy pin (no args) — show current pin
if len(args) == 0 {
if cfg.PinnedClaudeAccount == "" {
fmt.Println("No pin is active. All Claude requests use automatic account selection.")
} else {
fmt.Printf("Pinned Claude account: %s\n", cfg.PinnedClaudeAccount)
}
return nil
}

// cq proxy pin --clear
if len(args) == 1 && args[0] == "--clear" {
cfg.PinnedClaudeAccount = ""
Expand All @@ -70,25 +80,30 @@ func runProxyPin(args []string) error {

// cq proxy pin <email-or-uuid>
if len(args) == 1 {
cfg.PinnedClaudeAccount = args[0]
arg := args[0]
lower := strings.ToLower(arg)

// Reject reserved words that look like commands but aren't flags.
if lower == "clear" || lower == "remove" {
fmt.Fprintf(os.Stderr, "Usage: cq proxy pin [--clear | <email-or-account-uuid>]\n")
return fmt.Errorf("reserved word %q is not valid; did you mean --clear?", arg)
}

// Reject any argument that looks like an unknown flag.
if strings.HasPrefix(arg, "-") {
fmt.Fprintf(os.Stderr, "Usage: cq proxy pin [--clear | <email-or-account-uuid>]\n")
return fmt.Errorf("unknown flag %q", arg)
}

cfg.PinnedClaudeAccount = arg
if err := proxy.SaveConfig(cfg); err != nil {
return fmt.Errorf("save config: %w", err)
}
fmt.Printf("Pinned Claude account set to %q.\n", args[0])
fmt.Printf("Pinned Claude account set to %q.\n", arg)
fmt.Println("A running proxy will pick up the change shortly.")
return nil
}

// cq proxy pin (no args) — show current pin
if len(args) == 0 {
if cfg.PinnedClaudeAccount == "" {
fmt.Println("No pin is active. All Claude requests use automatic account selection.")
} else {
fmt.Printf("Pinned Claude account: %s\n", cfg.PinnedClaudeAccount)
}
return nil
}

fmt.Fprintf(os.Stderr, "Usage: cq proxy pin [--clear | <email-or-account-uuid>]\n")
return fmt.Errorf("unexpected arguments")
}
Expand Down Expand Up @@ -310,6 +325,21 @@ func runProxyStart(opts proxyCommandOptions) error {
}
}

var diagnostics *proxy.DiagnosticsWriter
if cfg.DiagnosticsLog != "" {
diagnostics, err = proxy.OpenDiagnosticsWriter(cfg.DiagnosticsLog)
if err != nil {
fmt.Fprintf(os.Stderr, "cq: diagnostics: %v (continuing without diagnostics)\n", err)
} else {
fmt.Fprintf(os.Stderr, "cq: diagnostics enabled\n")
defer func() {
if err := diagnostics.Close(); err != nil {
fmt.Fprintf(os.Stderr, "cq: diagnostics: close: %v\n", err)
}
}()
}
}

srv := &proxy.Server{
Config: cfg,
Selector: selector,
Expand All @@ -319,6 +349,7 @@ func runProxyStart(opts proxyCommandOptions) error {
CodexTransport: codexTransport,
CodexUpgradeTransport: codexUpgradeTransport,
Headroom: headroom,
Diag: diagnostics,
HeadroomMode: resolvedMode,
Catalog: catalog,
Refresher: proxyRefresher,
Expand Down
195 changes: 195 additions & 0 deletions cmd/cq/proxy_pin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
package main

import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/jacobcxdev/cq/internal/proxy"
)

// setupPinTest isolates proxy config to a temp dir and optionally seeds an
// existing pin value. Returns the config dir path for inspection.
func setupPinTest(t *testing.T, existingPin string) string {
t.Helper()
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)

if existingPin != "" {
// Seed a config with the given pin so tests that need a pre-existing
// value can verify it remains unchanged.
cfg, err := proxy.LoadConfig()
if err != nil {
t.Fatalf("seed LoadConfig: %v", err)
}
cfg.PinnedClaudeAccount = existingPin
if err := proxy.SaveConfig(cfg); err != nil {
t.Fatalf("seed SaveConfig: %v", err)
}
}
return filepath.Join(dir, "cq")
}

// loadPin reads the persisted pin from the proxy config under XDG_CONFIG_HOME.
func loadPin(t *testing.T) string {
t.Helper()
cfg, err := proxy.LoadConfig()
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
return cfg.PinnedClaudeAccount
}

func TestProxyPin(t *testing.T) {
t.Run("no args no pin configured prints message", func(t *testing.T) {
setupPinTest(t, "")
// No pin is set; runProxyPin(nil) should return nil and print no-pin message.
if err := runProxyPin(nil); err != nil {
t.Fatalf("runProxyPin(nil) returned error: %v", err)
}
})

t.Run("no args with pin configured prints pin", func(t *testing.T) {
setupPinTest(t, "pinned@example.com")
if err := runProxyPin(nil); err != nil {
t.Fatalf("runProxyPin(nil) returned error: %v", err)
}
// Pin should remain unchanged.
if got := loadPin(t); got != "pinned@example.com" {
t.Errorf("pin = %q, want %q", got, "pinned@example.com")
}
})

t.Run("--clear clears existing pin", func(t *testing.T) {
setupPinTest(t, "user@example.com")
if err := runProxyPin([]string{"--clear"}); err != nil {
t.Fatalf("runProxyPin(--clear) returned error: %v", err)
}
if got := loadPin(t); got != "" {
t.Errorf("pin after --clear = %q, want empty", got)
}
})

t.Run("clear (bare word) returns error and leaves pin unchanged", func(t *testing.T) {
setupPinTest(t, "user@example.com")
err := runProxyPin([]string{"clear"})
if err == nil {
t.Fatal("runProxyPin(clear) expected error, got nil")
}
if !strings.Contains(err.Error(), "clear") {
t.Errorf("error %q does not mention 'clear'", err.Error())
}
if got := loadPin(t); got != "user@example.com" {
t.Errorf("pin changed to %q, want %q", got, "user@example.com")
}
})

t.Run("remove (bare word) returns error and leaves pin unchanged", func(t *testing.T) {
setupPinTest(t, "user@example.com")
err := runProxyPin([]string{"remove"})
if err == nil {
t.Fatal("runProxyPin(remove) expected error, got nil")
}
if !strings.Contains(err.Error(), "remove") {
t.Errorf("error %q does not mention 'remove'", err.Error())
}
if got := loadPin(t); got != "user@example.com" {
t.Errorf("pin changed to %q, want %q", got, "user@example.com")
}
})

t.Run("CLEAR (case-insensitive) returns error and leaves pin unchanged", func(t *testing.T) {
setupPinTest(t, "user@example.com")
err := runProxyPin([]string{"CLEAR"})
if err == nil {
t.Fatal("runProxyPin(CLEAR) expected error, got nil")
}
if got := loadPin(t); got != "user@example.com" {
t.Errorf("pin changed to %q, want %q", got, "user@example.com")
}
})

t.Run("REMOVE (case-insensitive) returns error and leaves pin unchanged", func(t *testing.T) {
setupPinTest(t, "user@example.com")
err := runProxyPin([]string{"REMOVE"})
if err == nil {
t.Fatal("runProxyPin(REMOVE) expected error, got nil")
}
if got := loadPin(t); got != "user@example.com" {
t.Errorf("pin changed to %q, want %q", got, "user@example.com")
}
})

t.Run("unknown flag returns error and leaves pin unchanged", func(t *testing.T) {
setupPinTest(t, "user@example.com")
err := runProxyPin([]string{"--help"})
if err == nil {
t.Fatal("runProxyPin(--help) expected error, got nil")
}
if got := loadPin(t); got != "user@example.com" {
t.Errorf("pin changed to %q, want %q", got, "user@example.com")
}
})

t.Run("other flag-like arg returns error and leaves pin unchanged", func(t *testing.T) {
setupPinTest(t, "user@example.com")
err := runProxyPin([]string{"--unknown"})
if err == nil {
t.Fatal("runProxyPin(--unknown) expected error, got nil")
}
if got := loadPin(t); got != "user@example.com" {
t.Errorf("pin changed to %q, want %q", got, "user@example.com")
}
})

t.Run("valid email sets pin", func(t *testing.T) {
setupPinTest(t, "")
if err := runProxyPin([]string{"new@example.com"}); err != nil {
t.Fatalf("runProxyPin(email) returned error: %v", err)
}
if got := loadPin(t); got != "new@example.com" {
t.Errorf("pin = %q, want %q", got, "new@example.com")
}
})

t.Run("UUID-like value sets pin", func(t *testing.T) {
setupPinTest(t, "")
uuid := "550e8400-e29b-41d4-a716-446655440000"
if err := runProxyPin([]string{uuid}); err != nil {
t.Fatalf("runProxyPin(uuid) returned error: %v", err)
}
if got := loadPin(t); got != uuid {
t.Errorf("pin = %q, want %q", got, uuid)
}
})

t.Run("multiple args returns usage error", func(t *testing.T) {
setupPinTest(t, "")
err := runProxyPin([]string{"one@example.com", "two@example.com"})
if err == nil {
t.Fatal("runProxyPin with multiple args expected error, got nil")
}
})
}

// TestProxyPinNoConfigDirCreation verifies that read-only operations (show
// current pin) do not fail when XDG_CONFIG_HOME is set to a non-existent path.
// The LoadConfig path will create the directory on first run, so this test
// just verifies no crash occurs on a fresh temp dir with no prior config.
func TestProxyPinFreshConfig(t *testing.T) {
dir := t.TempDir()
// Point at a sub-directory that doesn't exist yet.
configHome := filepath.Join(dir, "new-config")
t.Setenv("XDG_CONFIG_HOME", configHome)

// LoadConfig will create the dir and generate a default config.
if err := runProxyPin(nil); err != nil {
t.Fatalf("runProxyPin(nil) on fresh config: %v", err)
}

// Verify the config file was created.
if _, err := os.Stat(filepath.Join(configHome, "cq", "proxy.json")); err != nil {
t.Errorf("proxy.json not created: %v", err)
}
}
Binary file modified docs/screenshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
27 changes: 25 additions & 2 deletions internal/proxy/codex_compact.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"io"
"net/http"
"os"
"time"
)

// handleCodexCompactResponsesRoute handles POST /v1/responses/compact.
Expand Down Expand Up @@ -55,6 +56,28 @@ func rejectCodexCompactWebSocket(w http.ResponseWriter, requestPath string) {
// 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) {
start := time.Now()
var model string
ctx, routeDiag := withRouteDiagnostics(r.Context())
if wrapped, rec := s.wrapDiagnosticsResponseWriter(w); rec != nil {
w = wrapped
defer func() {
event := RouteEvent{
Time: start.UTC(),
Method: r.Method,
Path: r.URL.Path,
Provider: "codex",
RouteKind: "codex_compact",
Model: model,
StatusCode: rec.statusCode(),
LatencyMS: time.Since(start).Milliseconds(),
Error: rec.diagnosticsError(),
}
event.applyRouteDiagnostics(routeDiag)
s.emitDiagnostics(event)
}()
}

if s.CodexTransport == nil {
writeError(w, http.StatusServiceUnavailable, "api_error", "no codex accounts configured")
return
Expand All @@ -72,12 +95,12 @@ func (s *Server) handleNativeCodexCompact(w http.ResponseWriter, r *http.Request
return
}

model := extractModel(body)
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))
upReq, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL, bytes.NewReader(body))
if err != nil {
writeError(w, http.StatusInternalServerError, "api_error", fmt.Sprintf("create upstream request: %v", err))
return
Expand Down
7 changes: 7 additions & 0 deletions internal/proxy/codex_selector.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,10 @@ func codexAcctIdentifier(a *codex.CodexAccount) string {
}
return a.AccessToken
}

func codexAccountHint(a *codex.CodexAccount) string {
if a == nil {
return ""
}
return redactedAccountHint("codex", a.AccountID, a.Email, a.RecordKey, a.AccessToken)
}
3 changes: 3 additions & 0 deletions internal/proxy/codex_transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ func (t *CodexTokenTransport) RoundTrip(req *http.Request) (*http.Response, erro
if err != nil {
return nil, err
}
noteRouteAccount(req.Context(), codexAccountHint(acct), false)

resp, err := t.doRequest(req, acct)
if err != nil {
Expand Down Expand Up @@ -190,6 +191,7 @@ func (t *CodexTokenTransport) handleUnauthorized(req *http.Request, failedAcct *
codexAcctIdentifier(failedAcct), codexAcctIdentifier(alt))

t.persistSwitch(alt)
noteRouteAccount(req.Context(), codexAccountHint(alt), true)
resp, err := t.doRequest(req, alt)
if err != nil {
return nil, err
Expand Down Expand Up @@ -241,6 +243,7 @@ func (t *CodexTokenTransport) handle429(req *http.Request, resp *http.Response,
return makeBufferedResponse(fallbackResp, fallbackBody), nil
}

noteRouteAccount(req.Context(), codexAccountHint(alt), true)
altResp, err := t.doRequest(req, alt)
if err != nil {
return nil, err
Expand Down
Loading
Loading