Skip to content

feat: WorldRouter provider — OAuth + chat integration#126

Open
sean-tomo wants to merge 39 commits into
masterfrom
feat/worldagent-provider
Open

feat: WorldRouter provider — OAuth + chat integration#126
sean-tomo wants to merge 39 commits into
masterfrom
feat/worldagent-provider

Conversation

@sean-tomo

Copy link
Copy Markdown
Collaborator

Summary

Add WorldRouter as a first-class inference provider in puffer + puffer-desktop. Users can now OAuth-login via Auth Station (or paste a sk-worldrouter-* api key), then pick WorldRouter (and any of the ~66 live-discovered models, e.g. qwen3.5-plus, kimi-k2.6, gpt-5.5) in the New Session modal and chat against inference-api.worldrouter.ai through the standard puffer --provider worldrouter subprocess path.

Originally landed under the name "worldagent"; rebranded mid-stream to "worldrouter" (commit rename worldagent -> worldrouter).

What's wired

puffer-cli

  • New crate puffer-provider-worldrouter: Auth Station OAuth flow (token-in-redirect, no PKCE) + 2-hop JWT → sk-worldrouter-* exchange via control-api /auth/exchange + /platform/v1/teams/{team_id}/keys
  • auth login worldrouter / auth set-api-key worldrouter / auth clear worldrouter end-to-end
  • Top-level --provider <id> flag overrides config's default_provider for TUI/Resume/Fork launch paths
  • OauthFamily::WorldRouter variant, ProviderDescriptor.oauth_family for unambiguous dispatch
  • Fixed-port 127.0.0.1:1456 callback listener (Auth Station whitelist)

puffer-desktop (corbina)

  • worldrouter arm in build_provider_command spawning puffer --no-alt-screen --provider worldrouter <msg>
  • validate_provider_id / canonical_backend_provider_id / default_model_for updated
  • provider_command(\"puffer\") falls back to sibling-aware resolve_puffer_binary() so packaged corbina without puffer on PATH still works
  • Live model list: corbina forwards list_provider_models(\"worldrouter\") to puffer daemon via new query_daemon_rpc helper (no more hardcoded 2 seed models)
  • Detached OAuth subprocess + reaper thread emitting worldrouter:oauth-completed Tauri event
  • corbina parent process opens OAuth URL (workaround for macOS LaunchServices not routing through subprocess context)

puffer-desktop (frontend)

  • BUILTIN_AGENT_PROVIDER_IDS extended with worldrouter
  • LoginView: 3-state UI (not-connected / busy / connected) with worldrouter-specific "Waiting for browser login…" label; OAuth ↔ api_key mutual exclusion; Disconnect button; connected-state Reconnect / Update key controls preserved
  • App.svelte handles `worldrouter:oauth-completed` event for Tauri-mode busy clearance; falls through to standard RPC-await path in web mode (via isTauri() guard)
  • daemonClient subscribes to Tauri event channel even on WS daemons (fix for events not reaching frontend in Tauri+WS setup)
  • providerVisuals registers worldrouter with placeholder accent + generic `ai` icon (real brand asset pending from WorldRouter team)

Verification

  • cargo build (workspace): clean
  • cargo test -p puffer-provider-worldrouter: 6/6 pass
  • pnpm check (svelte-check): 0 errors / 0 warnings / 287 files
  • pnpm exec playwright test tests/worldrouter-ui.spec.ts: 3/3 pass
  • pnpm exec playwright test tests/settings-ui.spec.ts: 37/37 pass
  • pnpm exec playwright test tests/chat-session-ui.spec.ts tests/provider-interaction-ui.spec.ts tests/onboarding-ui.spec.ts: 138/138 pass
  • Real end-to-end (puffer-desktop Tauri host, real OAuth + real inference-api.worldrouter.ai):
    • WorldRouter Settings card → Connect with OAuth → browser login → Connected
    • New Session modal lists WorldRouter alongside the 3 builtin agents
    • ModelPicker shows 66 live-discovered models (qwen3.5-plus, qwen3.5-flash, qwen3.6-plus, qwen3-coder-plus, Kimi K2.6, kimi-k2.5, MiniMax-M2.7, gpt-5.5, llama-3.3-70b-instruct, nemotron-3-super-120b-a12b, …)
    • Chat turn against qwen3.5-flash returns assistant message; session jsonl persisted with assistant_message; daemon auto-generates title

Follow-ups (not in this PR)

See spec audit for details:

  • Cancel-turn HTTP abort (Slash command parity #6): killing the puffer subprocess on cancel doesn't propagate into the reqwest streaming response — server-side inference continues to bill. ~70 LOC cross-crate change (puffer-core SSE parser + puffer-cli stdin protocol + corbina graceful-cancel). Spec'd, deferred to follow-up PR.
  • OAuth refresh / key revoke leak (Codex OpenAI import parity #1, Claude tool parity #2): refresh_token is discarded at exchange time; sk-worldrouter-* keys never revoked on logout. Backend revoke endpoint pending; client-side multi-credential storage pending.
  • Remote daemon (SSH) OAuth (Align TUI layout with Claude-style panes #5): callback listener binds on daemon host; remote setups can't reach loopback. Needs manual-paste fallback URL.
  • Brand assets (Align Puffer CLI subcommands with Claude-style surface #7): placeholder accent #1f6feb + generic ai.svg icon — awaiting WorldRouter brand asset.
  • compat.rs per-model dispatch (Fix inline TUI scrollback and composer dropdowns #12): worldrouter currently falls through to OpenAI-strict defaults; Kimi via worldrouter will 400 on reasoning_effort for real tool-calling sessions. Needs a worldrouter dispatch branch.
  • `--provider ` validation (Unify conversation context pipeline across all providers #18): typos silently set default_provider; should validate against registry up-front.
  • ModelPicker no-auth fallback: registry.rs:316 skips discovery when no auth — worldrouter /v1/models is actually unauthenticated; auth gate could be relaxed.

🤖 Generated with Claude Code

sean-tomo and others added 30 commits May 21, 2026 20:58
Adds `oauth_family: Option<String>` with `#[serde(default)]` to both
`ProviderDescriptor` and `ProviderPack`, threads it through
`ProviderPack::into_descriptor()`, and fixes all existing struct
literals in the same crate to include `oauth_family: None`. Existing
yaml that omits the field continues to parse cleanly (serde default =
None). Two new unit tests cover the round-trip deserialization.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add OauthFamily::WorldAgent variant, extend oauth_family_for_provider to
prefer the explicit oauth_family field before falling back to default_api,
and wire WorldAgent arms into both bundle functions and match exhaustiveness
stubs in main.rs / daemon.rs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…hape

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace the three todo!() stubs added in Task 8: wire
parse_worldagent_callback_input + WorldAgentOAuthCredentials into
run_login_flow, refresh_worldagent_oauth_token into oauth-refresh, and
a descriptive bail! into oauth-exchange (no code-exchange step for
worldagent). Also branches the CallbackListener bind to use
WORLDAGENT_CALLBACK_PORT for the worldagent provider, and adds the
worldagent_expires_at_ms() helper.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wire the WorldAgent OAuth branch in the daemon RPC handler: add
puffer_provider_worldagent imports, branch the CallbackListener bind
to use the fixed port 1456, and replace the todo!() stub with the
full token-extraction + JWT-decode + credential-store logic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add resources/providers/worldagent.yaml with oauth_family, auth_modes
(api_key + oauth), and discovery config. Add end-to-end test confirming
the yaml parses as ProviderPack and oauth_family flows through
into_descriptor so the runtime dispatches WorldAgent OAuth correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Promotes the private `now_ms` helper in puffer-provider-worldagent to
`pub fn worldagent_access_token_expires_at_ms()`, re-exports it from
lib.rs, and replaces the duplicated inline in daemon.rs and the local
`worldagent_expires_at_ms` fn in main.rs with calls to the single
canonical helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the stub exchange_jwt_for_api_key with a real POST to the
WR control-api preview endpoint; OAuth login now stores
StoredCredential::ApiKey (sk-worldrouter-…) directly, discarding the
JWT. Adds default_team_id to WorldAgentJwtProfile, uuid dep, and
env-overrideable WORLDAGENT_CONTROL_URL constant.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Switch WORLDAGENT_AUTH_BASE_URL default from Sandbox to Production
  (auth.worldrouter.ai). Production 127.0.0.1:1456 whitelist confirmed
  2026-05-20 (307 from /session/check probe).
- Add PUFFER_WORLDAGENT_TEAM_ID env-var fallback in
  exchange_jwt_for_api_key as a temporary workaround: Auth Station
  JWTs do not currently carry default_team_id; let the user/operator
  pin it via env until backend either adds the claim or moves to a
  teamless endpoint.
- Update existing test to remove the env var inside the test body so
  the no-fallback path is exercised independently of process env.
- Update handoff note: correct root cause of the env-var staleness
  (edit+append+save unreliable; remove + re-add is the fix), record
  the verified prod deployment hash + accepted-origins list.
E2E test on 2026-05-20 hit a 401 from control-api with body
{"detail":{"code":"litellm_unauthorized","message":"Invalid Infer
session token"}}. The previous error message swallowed the body
because the response wasn't always JSON-decodable. Read the response
as text first, then attempt JSON parsing only on success.

Also record the e2e finding in the backend-handoff note: the failure
mode confirms control-api consumes Infer session tokens
(HS256, iss=infer-session), not Auth Station JWTs (RS256,
iss=auth.worldrouter.ai). PUFFER_WORLDAGENT_TEAM_ID alone is not
enough — backend needs to either accept Auth Station JWTs directly
or expose an exchange endpoint.
The control-api litellm gateway only accepts Infer Session JWT
(HS256, iss=infer-session). Auth Station issues OIDC JWT
(RS256, iss=auth.worldrouter.ai), so a direct POST to the keys
endpoint hits a 401 from litellm.

infer-monorepo already exposes POST /auth/exchange which detects
an Auth Station JWT (looks_like_auth_station_token) and returns
{ session_token, default_team_id, ... } where session_token is
the HS256 token that litellm accepts. Use it: hop 1 trades the
Auth Station JWT, hop 2 calls /platform/v1/teams/{team_id}/keys
with the session_token to mint the WR inference api_key.

This removes the need for the PUFFER_WORLDAGENT_TEAM_ID env
fallback (default_team_id now comes from the exchange response)
and simplifies the TODO block down to genuine backend-side
follow-ups: idempotent get-or-create, revoke-on-logout, stable
production URL.

Verified end-to-end 2026-05-21:
  stored oauth credentials for worldagent
  ~/.puffer/auth.json → worldagent: kind=api_key,
  key=sk-worldrouter-...
… seed models

Preview control-api (control-api-pre-7f819c.worldrouter.ai) mints
keys into a preview LiteLLM DB that production
inference-api.worldrouter.ai does not read; e2e attempts produced
401 token_not_found_in_db on inference. Production control-api
also supports /auth/exchange (422 probe confirmed) and pairs
correctly with production inference.

E2E verified live 2026-05-21:
  GET  inference-api.worldrouter.ai/v1/models   -> 200 (qwen/kimi/glm/minimax catalogue)
  POST inference-api.worldrouter.ai/v1/chat/completions  -> 200 "pong"

Replace the placeholder gpt-5 seed model with two real entries
(kimi-k2.6, qwen3.5-flash) both verified live; runtime discovery
still rewrites the catalogue from /v1/models.
- Add WorldAgent ProviderSummaryDto with oauth+api_key auth modes and
  two placeholder models (kimi-k2.6, qwen3.5-flash)
- Read worldagent auth status from ~/.puffer/auth.json via
  read_puffer_cli_credential() so the GUI shows "Connected" after login
- Wire login_with_oauth / login_with_api_key / logout_provider to spawn
  `puffer auth login|set-api-key|clear worldagent` as a subprocess
- Add run_puffer_cli_auth_subcommand() helper with polling + timeout
- Declare daemon_launcher module in lib.rs; make resolve_puffer_binary pub(crate)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous helper blocked the Tauri command handler for up to
180 s waiting for puffer auth login to complete (which itself
waits up to 120 s on the localhost callback listener). Result:
GUI froze the moment the user clicked Connect with OAuth.

Split behavior: `wait = false` for OAuth (spawn-and-detach,
frontend re-polls load_settings_snapshot via the Refresh button),
`wait = true` for set-api-key / clear (fast, sub-second
operations).
The puffer CLI invokes macOS `open` to launch the browser, but
when puffer runs as a corbina-spawned subprocess it lacks the GUI
context LaunchServices needs — the `open` call silently no-ops
even though the subprocess itself is fine.

corbina is a proper macOS app and its own `open` invocations
route through LaunchServices correctly. Pipe the OAuth child's
stdout, tail it line-by-line in a background thread, and when we
see the `https://…` URL line, invoke `open` from corbina
itself. Other stdout lines forward to stderr so dev terminal
visibility is preserved. Also reap the detached child in another
thread to avoid zombies.
CallbackListener::wait_for_callback_url called accept() on a
nonblocking listener and then immediately read from the accepted
stream. On macOS the accepted stream inherits the listener's
nonblocking flag, so if the browser's HTTP request hasn't fully
arrived yet the first read returns EAGAIN (`Resource temporarily
unavailable, os error 35`) and the whole login flow bails out.

The race showed up when puffer is spawned as a subprocess of
corbina (slightly slower scheduling than direct CLI exec) — the
detached GUI worldagent OAuth flow hit it every time even though
the equivalent direct `puffer auth login worldagent` had never
reproduced it.

Switch the accepted stream back to blocking with a 5 s read
timeout so we wait for the request bytes instead of aborting.
…states, disconnect

The detached `puffer auth login worldagent` subprocess now reports its
exit status back to the GUI via a new `worldagent:oauth-completed`
Tauri event, so the LoginView keeps showing "Waiting for browser
login…" until the user finishes (or cancels) the browser flow instead
of optimistically flipping to the workspace screen the moment the
child is spawned. After a successful login the provider card hides
the API-key input and OAuth button and shows a "Disconnect" affordance
plus a Connected badge; the API-key field and OAuth button are mutually
exclusive when one of them is in use; and Settings now forwards
`onLogout` into the per-provider LoginView so users can disconnect
without leaving the page.

Backend also distinguishes worldrouter-minted `sk-worldrouter-…`
tokens (kind = "oauth") from manually pasted api keys so the GUI can
render the right affordance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code review (PR #/branch feat/worldagent-provider) flagged the spec
as the only blocker for merge:
- PUFFER_WORLDAGENT_TEAM_ID env var was dropped in the two-hop
  implementation (default_team_id arrives in /auth/exchange response).
- Default control-api URL is production, not the preview deployment.
- Surface description for exchange_jwt_for_api_key referenced only
  one hop; document both hops and the litellm DB split that motivates
  defaulting to production control-api.
sean-tomo and others added 9 commits May 21, 2026 21:00
Mechanical rebrand of the WorldAgent provider integration to WorldRouter
prior to merging feat/worldagent-provider to master. Covers:

- Crate dir: crates/puffer-provider-worldagent -> puffer-provider-worldrouter
  (Cargo.toml name, workspace members, puffer-cli dependency, extern crate
  references all updated)
- Provider yaml: resources/providers/worldagent.yaml -> worldrouter.yaml
  with id/display_name/oauth_family flipped
- Rust: OauthFamily::WorldAgent -> WorldRouter, *_worldagent_* fns +
  WORLDAGENT_* constants, "worldagent" provider-id match arms in
  auth_provider.rs / backend.rs
- Env vars: PUFFER_WORLDAGENT_AUTH_URL / _CONTROL_URL -> PUFFER_WORLDROUTER_*
- Tauri events: worldagent:oauth-completed -> worldrouter:oauth-completed,
  log prefix [worldagent oauth] -> [worldrouter oauth]
- Desktop TS/Svelte literals (App.svelte, LoginView.svelte, providerVisuals.ts)
- Specs (specs/puffer-provider-worldagent dir renamed, plus references in
  puffer-cli/desktop/registry/resources specs)
- docs/superpowers spec/plan/notes files renamed

Marketing URL https://inference-api.worldrouter.ai untouched (already
WorldRouter). No backwards-compat shim for auth.json keyed by "worldagent" -
existing users will need to re-login.

cargo build green; cargo test -p puffer-provider-worldrouter 6/6 passing;
pnpm check (svelte-check) 0 errors / 0 warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- puffer-cli: add top-level `--provider` flag and apply it to
  config.default_provider for the TUI launch paths (None / Resume / Fork)
  so the in-process registry picks WorldRouter as the active provider.
- puffer-desktop backend: add `"worldrouter"` arm to build_provider_command
  that spawns `puffer --no-alt-screen --provider worldrouter <msg>`.
- puffer-desktop validators: accept `"worldrouter"` in validate_provider_id,
  map it through canonical_backend_provider_id, and seed default_model_for
  with `kimi-k2.6` to match the listed provider_models entry.
- puffer-desktop frontend: include `worldrouter` in BUILTIN_AGENT_PROVIDER_IDS
  so the NewSessionModal renders it and add a "WorldRouter inference"
  detail label.

Auth continues to flow through puffer-cli's AuthStore — no env-var
injection from the desktop wrapper, so the stored sk-worldrouter-… key
remains the source of truth.
…router UI spec

c6a9c10 collapsed the connected-state provider card down to just a Disconnect
button, hiding the OAuth and api_key controls. That broke 10 settings-ui tests
that expect Reconnect / Update key controls to remain visible once a provider
is connected.

Restore master's flat layout for the card body (imports + actions with OAuth
and api_key sub-blocks rendered regardless of auth state), while preserving
the WorldRouter UX additions:

- OAuth button: still uses "Waiting for browser login…" busy label when the
  provider is worldrouter (master used "Opening browser…" universally).
- OAuth button: still disabled when an api_key is pending, with the
  "Clear the API key field to use OAuth" tooltip (mutual-exclusion).
- Disconnect button (new in c6a9c10) now lives inside .actions, gated on
  {#if auth}, and disables to "Disconnecting…" while busy.

Also stage the previously-untracked tests/worldrouter-ui.spec.ts which covers
the WorldRouter provider card and new-agent modal radio.
Cleanup orphan CSS selectors left over from the c6a9c10 connected-state
refactor — those rules were unreachable after restoring master's flat
provider-card layout in 7a0e9c5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Web mode (vite + WebSocket daemon) doesn't have the Tauri event system,
so the worldrouter branch was leaving authBusyProviderId set forever and
the LoginView stayed stuck on "Waiting for browser login..." after a
successful login. Gate the detached/event-driven path on isTauri() so
web sessions go through the standard awaited RPC path, while the Tauri
desktop app keeps its detached-subprocess flow unchanged.

Verified end-to-end against a real puffer daemon in headed Chromium:
OAuth completes, Settings card flips to Connected, NewSessionModal
picks WorldRouter, and a qwen3.5-flash turn round-trips to
inference-api.worldrouter.ai and back.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The on() helper used to dispatch by useWebSocket: WS daemons skipped the
Tauri listen() path entirely. That broke corbina-emitted events like
worldrouter:oauth-completed, which travel on app.emit("corbina:event")
and never reach the puffer daemon's WebSocket — so the GUI stayed stuck
on "Waiting for browser login..." even after a successful login.

Subscribe to both channels when Tauri is available. Each channel has its
own cleanup; unsubscribe runs them all.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`provider_command("puffer")` resolved the binary via env override / PATH
only, while `run_puffer_cli_auth_subcommand` already routed through
`daemon_launcher::resolve_puffer_binary()` which walks up to
`target/<profile>/puffer` and checks siblings of the Tauri host. That
mismatch meant a packaged corbina build (which bundles `puffer` next to
`puffer-desktop` but typically does not put it on PATH) could complete
worldrouter OAuth login yet fail every chat turn with
`` `puffer` is not installed ``.

Hook the sibling-aware resolver into `provider_command` for the puffer
arm only, after the env override and before falling back to the bare
`"puffer"` PATH lookup. Guard on `path.exists()` so the resolver's own
last-resort `PathBuf::from("puffer")` return doesn't mask the existing
PATH-based default and the informative error in `ensure_provider_command`.
Codex/Claude arms are untouched — their CLIs are external and should
continue to error when missing from PATH.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The corbina backend's `list_provider_models` Tauri/WS handler used to
return a hardcoded `[kimi-k2.6, qwen3.5-flash]` pair for worldrouter —
the same two seeds we ship in `resources/providers/worldrouter.yaml`.
That hid the ~60 extra models the relay actually exposes via
`/v1/models`, which the puffer daemon already discovers and merges into
its `ProviderRegistry` on startup (or on demand inside
`handle_list_provider_models`).

This wires corbina to that existing daemon RPC instead:

  * `DaemonLauncher::current_handshake()` returns the spawned daemon's
    URL+token without forcing a fresh spawn — `ensure_started`'s
    side-effecting variant is not what we want from a per-request
    code path.
  * `daemon_launcher::query_daemon_rpc()` is a small synchronous
    tungstenite client that does one JSON-RPC round trip: builds the
    `?token=…` URL the same way the frontend's DaemonClient does,
    sends `{ id, method, params }`, and waits for the matching
    `{ id, ok, result | error }` (event frames in between are skipped).
    Sets read/write timeouts so a hung daemon can't pin the ModelPicker.
  * `BackendState` now optionally holds an `Arc<DaemonLauncher>`. When
    `list_provider_models` is invoked for worldrouter, we forward to
    the daemon; for any other provider (puffer/codex/claude/openai)
    we keep the existing hardcoded `provider_models()` path so their
    catalogs are unchanged.
  * On any failure (daemon down, network timeout, parse error) we fall
    back to the descriptor seed — better to show two seed models than
    spin forever.

Verified with `cargo build` (workspace) and `cd
apps/puffer-desktop && pnpm check` (0 errors, 0 warnings). All 55
existing corbina unit tests still pass, and the daemon-side
`list_provider_models_uses_fresh_discovery_over_static_models` test
already covers the discovery-merge behavior on the other side of
the RPC.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Transitive `rand` bump (0.9.2 → 0.9.4) picked up during post-rebase build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant