feat: WorldRouter provider — OAuth + chat integration#126
Open
sean-tomo wants to merge 39 commits into
Open
Conversation
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.
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.
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 againstinference-api.worldrouter.aithrough the standardpuffer --provider worldroutersubprocess path.Originally landed under the name "worldagent"; rebranded mid-stream to "worldrouter" (commit
rename worldagent -> worldrouter).What's wired
puffer-cli
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}/keysauth login worldrouter/auth set-api-key worldrouter/auth clear worldrouterend-to-end--provider <id>flag overrides config's default_provider for TUI/Resume/Fork launch pathsOauthFamily::WorldRoutervariant,ProviderDescriptor.oauth_familyfor unambiguous dispatch127.0.0.1:1456callback listener (Auth Station whitelist)puffer-desktop (corbina)
worldrouterarm inbuild_provider_commandspawningpuffer --no-alt-screen --provider worldrouter <msg>validate_provider_id/canonical_backend_provider_id/default_model_forupdatedprovider_command(\"puffer\")falls back to sibling-awareresolve_puffer_binary()so packaged corbina withoutpufferon PATH still workslist_provider_models(\"worldrouter\")to puffer daemon via newquery_daemon_rpchelper (no more hardcoded 2 seed models)worldrouter:oauth-completedTauri eventpuffer-desktop (frontend)
BUILTIN_AGENT_PROVIDER_IDSextended withworldrouterApp.sveltehandles `worldrouter:oauth-completed` event for Tauri-mode busy clearance; falls through to standard RPC-await path in web mode (viaisTauri()guard)Verification
cargo build(workspace): cleancargo test -p puffer-provider-worldrouter: 6/6 passpnpm check(svelte-check): 0 errors / 0 warnings / 287 filespnpm exec playwright test tests/worldrouter-ui.spec.ts: 3/3 passpnpm exec playwright test tests/settings-ui.spec.ts: 37/37 passpnpm exec playwright test tests/chat-session-ui.spec.ts tests/provider-interaction-ui.spec.ts tests/onboarding-ui.spec.ts: 138/138 passinference-api.worldrouter.ai):assistant_message; daemon auto-generates titleFollow-ups (not in this PR)
See spec audit for details:
sk-worldrouter-*keys never revoked on logout. Backend revoke endpoint pending; client-side multi-credential storage pending.#1f6feb+ genericai.svgicon — awaiting WorldRouter brand asset.compat.rsper-model dispatch (Fix inline TUI scrollback and composer dropdowns #12): worldrouter currently falls through to OpenAI-strict defaults; Kimi via worldrouter will 400 onreasoning_effortfor real tool-calling sessions. Needs a worldrouter dispatch branch.registry.rs:316skips discovery when no auth — worldrouter/v1/modelsis actually unauthenticated; auth gate could be relaxed.🤖 Generated with Claude Code