Skip to content

Facade routes in OpenAPI spec but not served by opencode serve (SPA/405 on /v1/*) #1627

Closed

Description

@dezren39

Facade routes are in the OpenAPI spec but not served by opencode serve (SPA/405)

Found during the #1146 deploy-gated sequence on generation 528 (2026-11-04). The #1146 facades answer in tests, but the deployed serve listener does not route them:

Evidence (running server, 127.0.0.1:13420, build 2.0.30+4175c63, pid 616146)

  • GET /openapi.json (basic auth) lists all facade paths: /v1/chat/completions, /v1/responses, /v1/embeddings, /v1/messages, /v1/systemone, /v1/models, /v1/models/{id}, /v1/models/{provider}/{model} (140 paths total).
  • GET /v1/models (with and without basic auth, with x-opencode-directory header) returns 200 with the web UI SPA HTML, not the models JSON.
  • POST /v1/chat/completions and POST /v1/responses (with/without auth, with directory header and ?directory= query) return 405 with empty body.
  • /api/* endpoints on the same listener work normally with the same auth (e.g. GET /api/credential returns data).
  • The binary contains the completions group (server.completions, completions.models strings present).

Reproduced on the pinned build too

A scratch instance of the pinned build (opencode-2.0.30+ae5d592, the flake.lock opencode_2 rev ae5d592) on a scratch port/HOME/DB behaves identically: SPA HTML for GET /v1/models, 405 for POST /v1/chat/completions. So this is not a stale-build artifact: at both revs, packages/server/src/handlers.ts registers CompletionsHandler and routes.ts mounts HttpApiBuilder.layer(Api) unconditionally, yet the serve listener does not route the group. The OpenAPI is generated from the Api definition, which is why the spec advertises routes the listener does not serve. Suspect the serve entry router composition (UI catch-all shadowing the API router for GET, no fall-through for POST), while the fetch-handler entry used in tests serves the API only — worth checking where serve composes its router versus ServerFetch.make.

Impact

Blocks the #1146 migration legs that repoint callers to the facade: #1410 (clef to /v1/systemone), #1153 (route-proxy retirement + OpenClaw rewire proof), the #1155 live attestation probe, and the facade leg of the #1154 Gate 1 verification window. Session traffic is unaffected.

Acceptance criteria

  • Every route advertised in the OpenAPI spec is either served by opencode serve or removed from the spec
  • The facade /v1/* behavior (SPA fallback / 405) is intentional and documented for any route kept in the spec
  • A test or scripted check compares spec routes against served routes

Activity

  1. added
    kind/bugSomething isn't working (prefixed form of the classic bug label)
    priority/normalOrdinary priority (the default)
    effort/mMedium: expected under 1 day of work
    meta/enrichedIntake review done: acceptance criteria, links, and normalized labels added
    on Nov 4, 2026
  2. dezren39 commented on Nov 4, 2026

    @dezren39
    Author

    Intake review

    • Related issues/PRs: merged PRs with this issue number in the title: none found in a merged-PR title search. No merged PR explicitly resolves this issue, so it stays open.
    • Notes: Enriched by the fleet monitor (Duty 2, protocol §5): acceptance criteria appended to the body, labels normalized (kind/bug, priority/normal, effort/m).
    • Risks / suggested approach: Generate the served-route list from the server and diff it against the spec; disposition each facade route (implement, or drop from spec). Risk: client codegen may already depend on spec-only routes.
  3. added a commit that references this issue on Nov 4, 2026
  4. dezren39 commented on Nov 4, 2026

    @dezren39
    Author

    Fixed — root cause, fix, and scratch-instance proof

    Root cause. The façade handlers and routes were never the problem. opencode serve composes its listener as WebUi.handler() wrapped around the API router (the transform argument of ServerProcess.start, built in packages/cli/src/server-process.ts). The transform in packages/cli/src/services/web-ui.ts passed only /api, /api/*, /auth/* and /openapi.json through to the router and answered every other path from the embedded web shell: GETs fell back to the SPA index.html (200 text/html), and non-GET/HEAD requests got the shell's bare 405. The completions group is the only API group outside /api — all eight façade paths live under /v1 — so the serve listener advertised the doors in its OpenAPI spec (generated from the Api definition) but routed none of them. The fetch-handler entry (ServerFetch.make, used by tests and the workerd/embedded entries) applies no transform, which is why the façade answered there while serve did not. This matches the issue's suspicion exactly: serve-entry router composition, not route registration.

    Fix. PR #1638 (merged, 63c50eec0d27139b6ebdc57ed85d4d3ace35b5b3): the WebUi transform now passes /v1 and /v1/* through to the API router alongside /api. One condition added, no other behavior change — the shell still owns every non-API path, and unknown /v1 paths now 404 through the router pass-through instead of serving the SPA. Regression coverage in packages/cli/test/web-ui.test.ts (the spec-vs-served check for this composition): the live ServerProcess test now asserts unauthenticated /v1/* meets the API auth gate, authenticated façade requests return façade JSON (models list, chat 400 invalid_request_error, systemone {error} envelope, embeddings JSON), and the stub-API tests cover /v1 pass-through with the UI disabled and /v1/missing → 404. 4/4 tests pass (116 assertions); tsgo -b typecheck in packages/cli passes; oxlint clean.

    Scratch-instance proof (fixed build from source, own port 13499, own data dir/DB, the deployed server's config overlay; plugin list emptied in the scratch config copy only, because a cold plugin cache cannot finish activation inside awaitPlugins' 5-second window — the deployed server's warm cache is unaffected):

    • GET / → 200 text/html (SPA shell intact)
    • GET /v1/models without auth → 401 application/json (the API auth gate, not the shell)
    • GET /v1/models with auth → 200 JSON: the full route catalog (deepseek-flash-plan, haiku-plan, terra-plan, sonnet-plan, opus-plan, … owned_by opencode-route) plus the embeddings entry local-embed/snowflake-arctic-embed-m-v2.0
    • GET /v1/models/deepseek-flash-plan → 200 JSON single-model object
    • POST /v1/chat/completions with a real route (deepseek-flash-plan, one-line prompt) → 502 JSON server_error — the request went through route resolution and failover and was ledgered: 12 route_attempt rows in the scratch DB with session_id NULL (the façade convention), including attempts on opendesign and antigravity-direct. It did not complete because the scratch instance has an empty credential store (and the antigravity client secret is separately broken today, antigravity: client-secret extraction (first GOCSPX match) stale for agy 1.4.11 — refresh fails invalid_client #1633) — a well-formed routing error from inside Generate, not a 405 and not the SPA.
    • POST /v1/responses (unknown model) → 400 JSON {"error":{"message":"Model unavailable: opencode-route/no-such-model","type":"invalid_request_error"}}
    • POST /v1/messages (unknown model) → 400 JSON in the Anthropic error shape, same message
    • POST /v1/systemone with the recorded clef classifier probe (the exact body transcribed in packages/core/test/facade-parity.test.ts) → 401 with the door's upstream-failure envelope, and a route_attempt row (provider cloudflare-ai-gateway, model @cf/cloudflare/clef-flash). Parity check: the same probe sent to the still-running standalone door at 127.0.0.1:18742/v1/systemone returned the byte-identical response (same envelope, same Cloudflare code 10000 "Authentication error", same 401). The upstream authentication failure is a pre-existing credential condition that currently affects the door and the façade identically; the façade's systemone is behaviorally indistinguishable from the door for this request.
    • POST /v1/embeddings → 502 JSON {"error":{"message":"Transport error (POST http://127.0.0.1:18752/v1/embeddings)","type":"server_error"}}, with route_attempt rows for local-embed. State of the embeddings door: the route is registered and served by the façade; its backend is still the HTTP sidecar registration (nothing listens on :18752, per step 5's gate), so requests answer a well-formed transport error. The Embeddings: bake the local backend into the fork (native in-process), retire the :18752 sidecar #1554 native-onnx flip is untouched by this change, as instructed.

    Deployment note. A plain restart of the running server does not suffice: the deployed binary is the nix build of the currently pinned fork rev, which predates this fix. The running server carries the fix only after the code repo's flake pin for the fork advances to a rev containing 63c50eec and the server is redeployed/restarted on the new build (a switch round, or a targeted redeploy of the server package plus a restart of the login-session server process). The switch round in flight at the time of this fix still uses the old pin.

    Unblocks (follow-ons not performed here, per the sequence's ownership):

  5. developing-today commented on Nov 4, 2026

    @developing-today

    Live verification on the deployed server — the redeploy this issue was held open for

    The fix is now live on the standing server. Path: code PR anomalyco#5567 advanced the fork pin to 63c50ee (and, via inputs.opencode.follows, ended the cascade float — code#5519), merged as 4d2eeecb; a switch on 2026-11-04 (~10:36 CST, SWITCH_EXIT=0) cycled opencode.service onto the pinned build. Verified live, in order:

    • Cascade check (pre-switch): with a fresh XDG_CACHE_HOME, the evaluated server package reads opencode-2.0.30+63c50ee-wrapped and flake.lock is byte-identical before/after evaluation (sha256 ebafe50b…) — the float no longer re-resolves anything.
    • Running build: the unit's ExecStart is /nix/store/m00436…-opencode-2.0.30+63c50ee-wrapped/bin/opencode serve --service --hostname 127.0.0.1 --port 13420; the process exe resolves to /nix/store/4r20ria3…-opencode-2.0.30+63c50ee/bin/.opencode-wrapped — exactly the outPath the PR's scratch verification built.
    • Façade battery on :13420 (deployed auth):

    The issue was closed by @dezren39 earlier today; this comment supplies the post-redeploy live evidence the close was waiting on. The unblocks recorded here have since executed: #1410 (repoint + door retirement) and the #1146 step-3 OpenClaw rewire behind #1153 — evidence on those issues.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    effort/mMedium: expected under 1 day of workkind/bugSomething isn't working (prefixed form of the classic bug label)meta/enrichedIntake review done: acceptance criteria, links, and normalized labels addedpriority/normalOrdinary priority (the default)status/needs-triageNot yet triaged; needs a first look

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions