#6558·ogx

`apis:` cannot disable `conversations`, and omitted routes return non-OpenAI-shaped 404s

Author: nathan-weinbergCreated Sep 15, 2026Updated Sep 15, 2026

Context

We deploy OGX behind an AI gateway (Praxis) via ogx-k8s-operator. In that topology the gateway is the single public entrypoint and serves /v1/responses and /v1/conversations itself; OGX runs as an internal resource backend and must not serve those two APIs. The operator's only lever is the generated config.yaml, so it removes them from the top-level apis: list.

While writing negative tests to prove OGX really stops serving them, we found three problems. Filing together because they share one root: the apis: list is not a reliable way to control the served HTTP surface. Happy to split if you'd prefer.

All references are to 0df79b5d143b8969601f244ca3ab974e4ebfddd4.


1. conversations is force-served regardless of apis: (bug)

src/ogx/core/server/server.py#L166-L181:

python
if app.stack.run_config.apis:
    apis_to_serve = set(app.stack.run_config.apis)
else:
    apis_to_serve = set(impls.keys())

for inf in builtin_automatically_routed_apis():
    if inf.router_api.value not in apis_to_serve:
        continue
    apis_to_serve.add(inf.routing_table_api.value)

apis_to_serve.add("admin")
apis_to_serve.add("inspect")
apis_to_serve.add("providers")
apis_to_serve.add("prompts")
apis_to_serve.add("conversations")   # <-- unconditional

The config-derived set is built and then conversations is added back unconditionally, so the router is registered at L183-L189 no matter what.

Omitting conversations from apis: has no effect. responses is not in that list, so route omission works correctly for it — the two APIs behave inconsistently.

admin, inspect, providers, and prompts are infrastructure endpoints and defensible as always-on. conversations is a user-facing OpenAI-compatible API and reads out of place there. If there's a deliberate reason (e.g. Responses depends on the Conversations impl in-process), the dependency could be satisfied by keeping the impl wired while gating only the HTTP router registration.

Expected: apis: omitting conversations means /v1/conversations is not served. Actual: it is served.

A secondary risk in the same loop: L185 is impl = impls[api], an unguarded index. Since impls is keyed off configured providers rather than apis: (resolver.py#L171), a distribution with no conversations provider would appear to raise KeyError during lifespan startup. I have not reproduced this and it may be unreachable in practice — flagging it only because the unconditional add is what would take it there.

2. Unregistered routes return a non-OpenAI-shaped 404

server.py#L544-L551 registers handlers for RequestValidationError, four domain errors, and bare Exception — but not for HTTPException / StarletteHTTPException. A request to a path with no matching route therefore falls through to Starlette's default handler and returns:

json
{"detail": "Not Found"}

For a server whose stated purpose is OpenAI compatibility, this is the one response an OpenAI client is most likely to hit against a partially-configured deployment, and it's the one that doesn't match the schema. Clients parsing error.message get an unexpected shape.

The machinery already exists — src/ogx_api/common/errors.py#L28:

python
class OpenAIErrorResponse(BaseModel):
    error: OpenAIErrorDetail   # message, type, code

so a single StarletteHTTPException handler routing through OpenAIErrorResponse would fix 404s and 405s globally.

Related: OpenAIErrorDetail.type defaults to None (L23), to_dict() uses exclude_none=True (L49), and global_exception_handler calls from_message() without type=. So OGX's OpenAI-shaped errors currently carry error.message but omit error.type, which the OpenAI API always includes. Worth fixing alongside.

3. An empty apis: list falls back to serving everything

server.py#L166 is if app.stack.run_config.apis:. An empty list is falsy in Python, so apis: [] is indistinguishable from an absent key and takes the else branch that serves every implementation.

A config that filters its way down to zero APIs therefore opens the full surface — the maximally-restrictive input produces the maximally-permissive result. if ... is not None would separate the two cases. The same falsy check appears at resolver.py#L171 (run_config.apis or set(...)).

This one is latent for us — our filtered starter config retains eight APIs — but it's a sharp edge pointed at exactly the use case in #1.


Suggested fixes

  1. Remove conversations from the unconditional block, or gate HTTP router registration on apis: while leaving the impl available in-process.
  2. Register a StarletteHTTPException handler that emits OpenAIErrorResponse, and populate error.type.
  3. Treat apis: [] as "serve nothing", distinct from an absent key.

Of these, (1) is the one that currently causes incorrect behaviour in a shipped configuration.

Verification status

These come from reading the source at the pinned SHA, not from a running server — so the reasoning is auditable from the permalinks above, but I haven't captured a runtime traceback. Happy to confirm against a live ogx run starter if useful, and glad to open a PR for any of the three if the direction sounds right.

Generated with Claude Code