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)
Summary
When an openai SDK client connects with
base_url="http://127.0.0.1:34891"(without the conventional
/v1suffix), JoyCodeProxy's SPA dashboardcatch-all route intercepts the SDK's
/chat/completionsrequest andreturns the dashboard HTML. The openai SDK silently treats the HTML body
as the response payload and hands it back as a plain
str. Downstreamcode accessing
response.choicesthen raisesAttributeError: 'str' object has no attribute 'choices', with noindication that the HTTP body was actually HTML.
This is a confusing failure mode because:
200 OK(the SPA route legitimately served the page).Content-Type: application/jsonis correctly missing — but the openaiSDK doesn't check it, so user code never sees the mismatch.
lingtai'sOpenAIVisionService) far from the actual cause — a routingdecision in the proxy.
Reproduction
Output:
With
base_url="http://127.0.0.1:34891/v1"(the explicit/v1form),the same code returns a proper
ChatCompletionobject.Why this matters
The openai SDK ships with the convention that the
/v1/prefix lives onthe server, not in the SDK call. From its docs:
So when users point at a local proxy with just the host:port, they very
naturally write
http://localhost:34891and expect/v1/...to beappended. The openai SDK, however, only appends
/chat/completions, not/v1/chat/completions. The convention is split: openai's own service URLdoes end in
/v1, but plenty of compatible proxies don't make thatobvious in setup docs.
The result is that quite a lot of openai-SDK-based callers (langchain
ChatOpenAI, lingtai'sOpenAIVisionService, opencode's relay client,custom wrappers) silently hit your
/chat/completionsroute, 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/*; thecatch-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/modelsA 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/...orsimilar) hitting an API-name path under
/and reply with a structuredJSON 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 firstcall." 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 KBbase64) go through fine on Kimi-K2.6 in ~26 s. Anthropic-style
/v1/messageswith the same image content block, however, doesn't —pkg/anthropic/translate.go'scontentBlockstruct has noSourcefield, 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
main(commitfae73a4)