Skip to content

SPA dashboard catch-all intercepts /chat/completions → openai SDK gets HTML, surfaces as 'str' has no attribute 'choices' #4

Description

@JieKiYu

Summary

When an openai SDK client connects with base_url="http://127.0.0.1:34891"
(without the conventional /v1 suffix), JoyCodeProxy's SPA dashboard
catch-all route intercepts the SDK's /chat/completions request and
returns the dashboard HTML. The openai SDK silently treats the HTML body
as the response payload and hands it back as a plain str. Downstream
code accessing response.choices then raises
AttributeError: 'str' object has no attribute 'choices', with no
indication that the HTTP body was actually HTML.

This is a confusing failure mode because:

  • The HTTP status is 200 OK (the SPA route legitimately served the page).
  • Content-Type: application/json is correctly missing — but the openai
    SDK doesn't check it, so user code never sees the mismatch.
  • The error surfaces in user code (or in a downstream library like
    lingtai's OpenAIVisionService) far from the actual cause — a routing
    decision in the proxy.

Reproduction

# Setup: JoyCodeProxy serving on 127.0.0.1:34891

python3 << 'PY'
import openai
client = openai.OpenAI(
    api_key="<your-joycode-key>",
    base_url="http://127.0.0.1:34891",   # ← note: no /v1 suffix
)
r = client.chat.completions.create(
    model="Kimi-K2.6",
    max_tokens=16,
    messages=[{"role": "user", "content": "say pong"}],
)
print(f"type={type(r).__name__}, len={len(r) if isinstance(r, str) else '-'}")
print(repr(r)[:200])
PY

Output:

type=str, len=701
'<!doctype html>\n<html lang="zh-CN">\n  <head>\n    <meta charset="UTF-8" />\n    <link rel="icon" type="image/x-icon" href="/favicon.ico" />\n    <meta name="viewport" content="width=device-width, initial-scale=1.0" />\n    <title>JoyCode 代理</title>\n    <script type="module" crossorigin src="/assets/index-BpWOi5QE.js"></script>'

With base_url="http://127.0.0.1:34891/v1" (the explicit /v1 form),
the same code returns a proper ChatCompletion object.

Why this matters

The openai SDK ships with the convention that the /v1/ prefix lives on
the server, not in the SDK call. From its docs:

base_url — The base URL to use. Defaults to https://api.openai.com/v1

So when users point at a local proxy with just the host:port, they very
naturally write http://localhost:34891 and expect /v1/... to be
appended. The openai SDK, however, only appends /chat/completions, not
/v1/chat/completions. The convention is split: openai's own service URL
does end in /v1, but plenty of compatible proxies don't make that
obvious in setup docs.

The result is that quite a lot of openai-SDK-based callers (langchain
ChatOpenAI, lingtai's OpenAIVisionService, opencode's relay client,
custom wrappers) silently hit your /chat/completions route, get HTML,
and surface inscrutable errors several layers up.

Suggested fix

Make the SPA dashboard route explicitly opt-in rather than catch-all.
The dashboard already lives at / and (presumably) /assets/*; the
catch-all is what reroutes everything else into the SPA so client-side
React Router can handle deep links. But known-API paths shouldn't get
that treatment.

Concretely — in the Go HTTP mux that serves both the SPA and the API
endpoints — exclude these paths from the SPA catch-all and return 404:

  • /chat/completions ← OpenAI-style, no /v1
  • /messages ← Anthropic-style, no /v1
  • /completions
  • /embeddings
  • /models
  • anything else known to be an API endpoint name

A 404 here would let the openai SDK surface a proper NotFoundError
("Not found at /chat/completions, did you mean /v1/chat/completions?"
would be even nicer if you can manage the body text).

Even better: detect openai-SDK-style probes (User-Agent: openai/... or
similar) hitting an API-name path under / and reply with a structured
JSON error:

{
  "error": {
    "type": "invalid_request_error",
    "message": "POST /chat/completions not found. JoyCodeProxy serves the OpenAI-compatible API under /v1/. Set your openai SDK base_url to http://<host>:<port>/v1"
  }
}

That converts the failure from "user code blows up with 'str' has no attribute 'choices'" to "user gets a clear actionable hint at the first
call." Cost is minimal — small mux change.

Related: vision request size

While debugging this I noticed that when the openai SDK does land on
the right URL with /v1, large image payloads (608 KB PNG → 811 KB
base64) go through fine on Kimi-K2.6 in ~26 s. Anthropic-style
/v1/messages with the same image content block, however, doesn't —
pkg/anthropic/translate.go's contentBlock struct has no Source
field, so image blocks are silently dropped during JSON unmarshalling
and the model receives only the text prompt. This is a separate issue
worth filing if you want vision via the Anthropic path too — happy to
write that up as a follow-up if useful.

Environment

  • JoyCodeProxy: latest main (commit fae73a4)
  • openai Python SDK: 2.37.0
  • macOS arm64, Python 3.13.0
  • Models tested: GLM-5.1 (text), Kimi-K2.5 (vision), Kimi-K2.6 (vision)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions