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
112 changes: 103 additions & 9 deletions internal/proxy/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const (
codexAppServerPath = "/app-server"
codexImagesPathPrefix = "/v1/images/"
legacyCodexImagesPathPrefix = "/images/"
codexSearchPath = "/alpha/search"
)

// RegistryRefresher is the interface for triggering a registry refresh.
Expand Down Expand Up @@ -125,6 +126,10 @@ func (s *Server) handler() (http.Handler, error) {
mux.HandleFunc(codexAppServerPath, s.handleCodexAppServerRoute)
mux.HandleFunc(codexImagesPathPrefix, s.handleCodexImagesRoute)
mux.HandleFunc(legacyCodexImagesPathPrefix, s.handleCodexImagesRoute)
mux.HandleFunc("POST "+codexSearchPath, s.handleCodexSearchRoute)
for _, pattern := range unsupportedOpenAICompatibleRoutePatterns() {
mux.HandleFunc(pattern, s.handleUnsupportedOpenAICompatibleRoute)
}
mux.HandleFunc("/", s.proxyHandler(upstream))
return mux, nil
}
Expand All @@ -143,16 +148,22 @@ func (s *Server) handleModels(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusMethodNotAllowed, "invalid_request_error", "/v1/models only supports GET")
return
}
if !s.isValidToken(bearerToken(r)) {
s.rejectInvalidProxyToken(w, r, "models", time.Now(), true)
return

var upstreamModels []ModelMetadata
if s.isValidToken(bearerToken(r)) {
upstreamModels = s.fetchUpstreamModels(r)
}

// /v1/models keeps runtime/API model metadata compatible with ANTHROPIC_BASE_URL.
// Claude Code's interactive /model picker is populated separately from
// additionalModelOptionsCache in ~/.claude.json; bootstrap/OAuth discovery does
// not use ANTHROPIC_BASE_URL and custom OAuth hosts are allowlist-restricted.
models := mergeModelMetadata(SyntheticModelCatalog(), registryCatalogModelMetadata(s.Catalog), s.fetchUpstreamModels(r))
//
// Local OpenAI-compatible clients also probe /v1/models when openai_base_url
// points at CQ, but they do not know CQ's local_token. Serve local metadata
// without auth and only query Claude upstream when the supplied bearer token
// is a valid CQ/Claude token, so arbitrary OpenAI API keys are never relayed.
models := mergeModelMetadata(SyntheticModelCatalog(), registryCatalogModelMetadata(s.Catalog), upstreamModels)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"data": models,
Expand Down Expand Up @@ -362,6 +373,14 @@ func (s *Server) handleCodexAppServerRoute(w http.ResponseWriter, r *http.Reques
}

func (s *Server) handleCodexImagesRoute(w http.ResponseWriter, r *http.Request) {
s.handleCodexHTTPRoute(w, r, "codex_images", "images", codexImagesUpstreamPath(r.URL.EscapedPath()))
}

func (s *Server) handleCodexSearchRoute(w http.ResponseWriter, r *http.Request) {
s.handleCodexHTTPRoute(w, r, "codex_search", "search", r.URL.EscapedPath())
}

func (s *Server) handleCodexHTTPRoute(w http.ResponseWriter, r *http.Request, routeKind, logKind, upstreamPath string) {
start := time.Now()
statusCode := 0
diagError := ""
Expand All @@ -371,7 +390,7 @@ func (s *Server) handleCodexImagesRoute(w http.ResponseWriter, r *http.Request)
Method: r.Method,
Path: r.URL.Path,
Provider: "codex",
RouteKind: "codex_images",
RouteKind: routeKind,
StatusCode: statusCode,
LatencyMS: time.Since(start).Milliseconds(),
Error: diagError,
Expand Down Expand Up @@ -413,7 +432,7 @@ func (s *Server) handleCodexImagesRoute(w http.ResponseWriter, r *http.Request)
}
}

upstreamURL := strings.TrimRight(s.Config.CodexUpstream, "/") + codexImagesUpstreamPath(r.URL.EscapedPath())
upstreamURL := strings.TrimRight(s.Config.CodexUpstream, "/") + upstreamPath
if r.URL.RawQuery != "" {
upstreamURL += "?" + r.URL.RawQuery
}
Expand All @@ -434,7 +453,7 @@ func (s *Server) handleCodexImagesRoute(w http.ResponseWriter, r *http.Request)
}
}

fmt.Fprintf(os.Stderr, "cq: route %s %s provider=codex (images)\n", r.Method, r.URL.Path)
fmt.Fprintf(os.Stderr, "cq: route %s %s provider=codex (%s)\n", r.Method, r.URL.Path, logKind)
resp, err := s.CodexTransport.RoundTrip(upReq)
if err != nil {
statusCode = http.StatusBadGateway
Expand All @@ -444,7 +463,7 @@ func (s *Server) handleCodexImagesRoute(w http.ResponseWriter, r *http.Request)
}
defer resp.Body.Close()

fmt.Fprintf(os.Stderr, "cq: proxy %s %s → %d (codex images)\n", r.Method, r.URL.Path, resp.StatusCode)
fmt.Fprintf(os.Stderr, "cq: proxy %s %s → %d (codex %s)\n", r.Method, r.URL.Path, resp.StatusCode, logKind)
for key, vals := range resp.Header {
for _, v := range vals {
w.Header().Add(key, v)
Expand All @@ -453,7 +472,7 @@ func (s *Server) handleCodexImagesRoute(w http.ResponseWriter, r *http.Request)
statusCode = resp.StatusCode
w.WriteHeader(resp.StatusCode)
if _, err := io.Copy(w, resp.Body); err != nil {
fmt.Fprintf(os.Stderr, "cq: codex images response copy: %v\n", err)
fmt.Fprintf(os.Stderr, "cq: codex %s response copy: %v\n", logKind, err)
}
}

Expand All @@ -464,6 +483,79 @@ func codexImagesUpstreamPath(path string) string {
return path
}

func unsupportedOpenAICompatibleRoutePatterns() []string {
return []string{
"/v1/responses/",
"/responses/",
"/v1/chat/completions",
"/chat/completions",
"/v1/completions",
"/completions",
"/v1/audio/",
"/audio/",
"/v1/embeddings",
"/embeddings",
"/v1/moderations",
"/moderations",
"/v1/realtime/",
"/realtime/",
"/v1/files",
"/v1/files/",
"/files",
"/files/",
"/v1/uploads",
"/v1/uploads/",
"/uploads",
"/uploads/",
"/v1/vector_stores",
"/v1/vector_stores/",
"/vector_stores",
"/vector_stores/",
"/v1/batches",
"/v1/batches/",
"/batches",
"/batches/",
"/v1/assistants",
"/v1/assistants/",
"/assistants",
"/assistants/",
"/v1/threads",
"/v1/threads/",
"/threads",
"/threads/",
"/v1/fine_tuning/",
"/fine_tuning/",
"/v1/evals",
"/v1/evals/",
"/evals",
"/evals/",
"/v1/containers",
"/v1/containers/",
"/containers",
"/containers/",
"/v1/conversations",
"/v1/conversations/",
"/conversations",
"/conversations/",
}
}

func (s *Server) handleUnsupportedOpenAICompatibleRoute(w http.ResponseWriter, r *http.Request) {
start := time.Now()
message := fmt.Sprintf("%s is an OpenAI-compatible endpoint that CQ does not support through Codex OAuth; supported local Codex endpoints are /v1/responses, /responses, /v1/images/*, /images/*, /alpha/search, /models, and /app-server", r.URL.Path)
writeError(w, http.StatusNotImplemented, "invalid_request_error", message)
s.emitDiagnostics(RouteEvent{
Time: start.UTC(),
Method: r.Method,
Path: r.URL.Path,
Provider: "codex",
RouteKind: "openai_unsupported",
StatusCode: http.StatusNotImplemented,
LatencyMS: time.Since(start).Milliseconds(),
Error: diagnosticsErrorCode("invalid_request_error", message),
})
}

func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
start := time.Now()
if wrapped, rec := s.wrapDiagnosticsResponseWriter(w); rec != nil {
Expand Down Expand Up @@ -1411,6 +1503,8 @@ func diagnosticsErrorCode(errType, message string) string {
return errType + ":no_codex_accounts"
case strings.Contains(msg, "websocket transport is not supported"):
return errType + ":unsupported_websocket_transport"
case strings.Contains(msg, "openai-compatible endpoint") && strings.Contains(msg, "does not support"):
return errType + ":unsupported_openai_endpoint"
case strings.Contains(msg, "requires websocket upgrade"):
return errType + ":websocket_upgrade_required"
case strings.Contains(msg, "only supports"):
Expand Down
144 changes: 141 additions & 3 deletions internal/proxy/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2333,7 +2333,7 @@ func TestServer_AppServerPrefersProAccountForSpark(t *testing.T) {
}
}

func TestServer_ModelsEndpointRequiresAuth(t *testing.T) {
func TestServer_ModelsEndpointAllowsLocalProbeWithoutAuth(t *testing.T) {
srv := &Server{Config: &Config{ClaudeUpstream: "https://api.anthropic.com", LocalToken: "tok"}}
handler, err := srv.handler()
if err != nil {
Expand All @@ -2344,8 +2344,146 @@ func TestServer_ModelsEndpointRequiresAuth(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
handler.ServeHTTP(w, req)

if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", w.Code)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", w.Code, w.Body.String())
}
}

func TestServer_CodexSearchAllowsLocalToolWithoutProxyToken(t *testing.T) {
var gotPath string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"results":[]}`))
}))
defer upstream.Close()

srv := &Server{
Config: &Config{
ClaudeUpstream: upstream.URL,
CodexUpstream: upstream.URL,
LocalToken: "secret-proxy-token",
},
CodexTransport: http.DefaultTransport,
}
handler, err := srv.handler()
if err != nil {
t.Fatalf("handler: %v", err)
}

w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, codexSearchPath, strings.NewReader(`{"query":"test"}`))
handler.ServeHTTP(w, req)

if w.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body = %s", w.Code, http.StatusOK, w.Body.String())
}
if gotPath != codexSearchPath {
t.Fatalf("upstream path = %q, want %q", gotPath, codexSearchPath)
}
if strings.Contains(w.Body.String(), "invalid proxy token") {
t.Fatalf("body contains invalid proxy token: %s", w.Body.String())
}
}

func TestServer_ModelsEndpointAllowsLocalOpenAIProbeWithoutProxyToken(t *testing.T) {
srv := &Server{Config: &Config{ClaudeUpstream: "https://api.anthropic.com", LocalToken: "tok"}}
handler, err := srv.handler()
if err != nil {
t.Fatalf("handler() error = %v", err)
}

w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
handler.ServeHTTP(w, req)

if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", w.Code, w.Body.String())
}
if strings.Contains(w.Body.String(), "invalid proxy token") {
t.Fatalf("body contains invalid proxy token: %s", w.Body.String())
}
var resp struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
for _, model := range resp.Data {
if model.ID == "gpt-5.4" {
return
}
}
t.Fatalf("missing gpt-5.4 synthetic model in response: %s", w.Body.String())
}

func TestServer_ModelsEndpointDoesNotForwardInvalidBearerToUpstream(t *testing.T) {
upstreamCalled := false
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upstreamCalled = true
t.Errorf("unexpected upstream request with Authorization %q", r.Header.Get("Authorization"))
w.WriteHeader(http.StatusTeapot)
}))
defer upstream.Close()

srv := &Server{Config: &Config{ClaudeUpstream: upstream.URL, LocalToken: "tok"}}
handler, err := srv.handler()
if err != nil {
t.Fatalf("handler() error = %v", err)
}

w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
req.Header.Set("Authorization", "Bearer sk-not-a-cq-token")
handler.ServeHTTP(w, req)

if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", w.Code, w.Body.String())
}
if upstreamCalled {
t.Fatal("upstream was called for invalid bearer token")
}
}

func TestServer_OpenAICompatibleUnsupportedPathWithoutProxyTokenDoesNotReturnInvalidProxyToken(t *testing.T) {
srv := &Server{
Config: &Config{
ClaudeUpstream: "https://api.anthropic.com",
CodexUpstream: "https://chatgpt.com/backend-api/codex",
LocalToken: "tok",
},
}
handler, err := srv.handler()
if err != nil {
t.Fatalf("handler() error = %v", err)
}

for _, path := range []string{
"/v1/chat/completions",
"/chat/completions",
"/v1/audio/transcriptions",
"/audio/transcriptions",
"/v1/responses/resp_123",
"/responses/resp_123",
"/v1/embeddings",
"/v1/files",
"/v1/files/file_123",
"/v1/realtime/sessions",
} {
t.Run(path, func(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"model":"gpt-5-search-api"}`))
handler.ServeHTTP(w, req)

if w.Code != http.StatusNotImplemented {
t.Fatalf("status = %d, want 501, body: %s", w.Code, w.Body.String())
}
if strings.Contains(w.Body.String(), "invalid proxy token") {
t.Fatalf("body contains invalid proxy token: %s", w.Body.String())
}
})
}
}

Expand Down