Skip to content

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

@JieKiYu

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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions