Skip to content

Commit ef60be8

Browse files
committed
Fix provider auth issues
1 parent efed9aa commit ef60be8

17 files changed

Lines changed: 528 additions & 82 deletions

‎AGENTS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ projects/
8080
- **Local model servers.** Two parallel discovery paths, deliberately not factored together: Ollama (`/ollama/models` → `OLLAMA_BASE_URL/api/tags`) and any OpenAI-compatible server (`/openai-compatible/models` → `OPENAI_COMPATIBLE_BASE_URL/v1/models`, default LM Studio's `:1234`). The latter's picker section stays hidden unless the env var was set (`configured`) or a server answers. Both URLs are live: `config.ts` exposes getters (`ollamaBaseUrl()`, `openaiCompatibleBaseUrl()`, `openaiCompatibleConfigured()`) read per call rather than import-time constants, and `api/credentials.ts` manages them as non-secret fields (`ollamaBaseUrl`, `openaiCompatibleBaseUrl`, echoed unmasked, http(s) `validate` hook) whose `onChange` calls `registerLocalProviders()` (`agent/models.ts`), so Settings → Providers → Local model servers applies without a restart and clearing one falls back to the default. Only `id` is read from `/v1/models`; refs are `openai-compatible/<id>` and may contain slashes, so only the prefix is stripped. Both providers are billed `local`/$0 in `cost/billing.ts`, `cost/ledger.ts`, and `agent/subagent-bridge.ts` — that trio must stay in sync or a local run defaults to `payg` and counts against the spend cap. Local-only by design: a hosted gateway would need real pricing, so route those through `OPENROUTER_BASE_URL` instead.
8181
- **Outbound proxy.** `server/src/http-proxy.ts` installs undici's `EnvHttpProxyAgent` as the global dispatcher at boot (`index.ts`, `isMain` block) when `HTTP_PROXY`/`HTTPS_PROXY` is set, and is a no-op otherwise. Node's fetch ignores those variables on its own (`NODE_USE_ENV_PROXY` is Node ≥ 24 and we support ≥ 22.19), and Pi's `configureHttpDispatcher` isn't in the package's public exports — without this the lead agent would dial providers directly while the subagent runner honoured the proxy. Note undici tunnels with `CONNECT` even for `http://` targets.
8282
- Pi OAuth tokens default to `~/.kady/pi-agent/auth.json`. `KADY_PI_AGENT_DIR` relocates that app-scoped directory; an explicitly supplied `PI_CODING_AGENT_DIR` takes precedence and can intentionally share a standalone Pi directory. The value is inherited by child `pi` processes, so lead and subagents share one auth store.
83+
- **Provider authentication contract.** `ProviderAuthManager` passes Pi's `LoginOptions.getDeviceId`, backed by `SettingsManager.getOrCreateDeviceId()` in the global agent directory, for current ChatGPT sign-in. Do not promote `.env` keys to `setRuntimeApiKey`: that overlay masks stored OAuth and is invisible to child runtimes. Pi's stored credential wins over environment keys; an explicit Settings key save removes the previous stored credential to select environment-key auth. Clearing an environment key preserves an independent OAuth login and writes empty `.env` assignments (including aliases) so stale shell/legacy keys do not reappear at restart. `setupModelRuntime` normalizes `OR_API_KEY` into the inherited `OPENROUTER_API_KEY`. `/model-providers.configured` describes effective key-or-OAuth availability; `connected` describes OAuth specifically. Real-Pi callback/manual-login regressions with mocked token exchanges live in `server/test/provider-auth-integration.test.ts`.
8384
- Settings exposes API keys (`/credentials`; model keys under Providers, web-search keys and the Modal pair under Services) plus Pi subscription authentication (`/model-providers`, `/model-auth/flows`; the row's **Sign in** button) for OpenAI (Sign in with ChatGPT; Pi 0.99 relabelled the older `openai-codex` login "OpenAI Codex (legacy)", kept for existing tokens), Anthropic (Claude Pro/Max), GitHub Copilot, xAI, Kimi Code, and Meta (Muse). OAuth browser/device/manual prompts are mediated by `server/src/agent/provider-auth.ts` and `server/src/api/model-providers.ts`. MCP servers, native web search (pi-web-access), and **durable Modal remote compute** (hybrid tools + per-chat selector + project Compute tab) are also implemented. **Not built in:** automated citation verification, first-party literature search, and document conversion (MCP connectors are the extension point).
8485

8586
**Capability hub.** The **Settings** dialog (`web/src/components/settings-dialog.tsx`, opened from the header gear) is the only customization surface, a grouped sidebar: **Models** (Providers · Defaults · Fusion), **Project · <name>** (General · Skills · Prompt templates · Specialists · Connectors) and **Workspace** (Services · Appearance). Panels live under `web/src/components/settings/` (`primitives.tsx`, `providers-panel.tsx`, `defaults-panel.tsx`, `project-settings-panel.tsx`, `services-panel.tsx`, `fusion-panel.tsx`, `key-row.tsx`, and `model-field.tsx` — the model picker with a "type a model id" escape hatch used by Defaults, Specialists and Watchdog). Providers merges OAuth sign-in and API-key entry into one row per provider (connected first; "Add a provider" has search, an All / Sign in / API key filter and "Show all"), plus Local model servers and Custom model servers cards; Services holds the web-search keys, the Modal token pair and a Remote cache card (`GET/DELETE /modal/cache`). Project → General replaces the old Edit project dialog (new projects still use the New project dialog) with four independently saved cards: General & budget, Agent instructions (AGENTS.md), Raw-data guard, Context compaction. Deep links go through `web/src/lib/settings-nav.ts` (`openSettings({tab, projectId?, section?})` dispatches `kady:open-settings`; legacy ids `model-providers`→`providers`, `api-keys`→`services`; last tab in localStorage `kady:settings:lastTab`) and `web/src/components/settings-link.tsx`; `app/page.tsx` listens gated on the active workspace and `project-view.tsx` gated on the Projects screen. Project settings are reached from the gear on a project row in the header switcher, "Project settings…" in a project card's ⋯ menu, spend-limit banner links, and the sliders button beside the context gauge (section `context`). With no usable model source (`useModels().hasAnyModelAccess === false`) an empty chat renders `connect-model-card.tsx` ("Connect a model to get started" → Providers), and the picker offers "Manage providers" and "Connect…" for a disconnected selection. Destructive actions in the Project tabs (delete specialist, restore default specialists/templates, clear memory, remove connector) confirm first. Enable/disable is non-destructive: skills move between `sandbox/.pi/skills/` and `sandbox/.pi/skills-disabled/`, project specialists between `sandbox/.pi/agents/` and `sandbox/.pi/agents-disabled/`, and MCP servers keep their entry with Pi's `enabled: false` (a legacy `sandbox/.pi/mcp-disabled.json` is folded in by `migrateDisabledMcpServers`). Builtin (pi-subagents package) specialists are disabled via `subagents.agentOverrides.<name>.disabled` in `sandbox/.pi/settings.json`. Sessions read only the canonical locations, so toggles apply to new chat tabs/subagent runs (live sessions keep their set).

‎docs/installation.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,18 @@ agents. `KADY_PI_AGENT_DIR` relocates that directory. An explicit
4747
`PI_CODING_AGENT_DIR` takes precedence; use it only when you intend to share
4848
Pi authentication and settings with another installation.
4949

50+
For providers with both methods, a successful sign-in takes precedence over an
51+
existing environment API key. Saving a new API key in Settings switches that
52+
provider back to API-key authentication. Disconnecting a sign-in removes its
53+
stored Pi credential; an API key still set in the environment remains usable.
54+
55+
Keep the sign-in dialog open while completing the provider's browser flow. If
56+
the browser runs on another machine, or the callback port is occupied, paste
57+
the final redirect URL into the dialog. ChatGPT requires the complete URL,
58+
including its state and issued client ID. Kady supplies Pi with a persistent
59+
installation ID in the global Pi settings; existing legacy Codex logins remain
60+
separate from the newer OpenAI sign-in.
61+
5062
## Optional services
5163

5264
**Settings → Services** accepts Exa, Perplexity and Gemini search keys and a

‎server/src/agent/models.ts‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -324,15 +324,17 @@ export function registerLocalProviders(modelRuntime: ModelRuntime): void {
324324
});
325325
}
326326

327-
/** Configure app-specific providers and runtime credentials. */
327+
/** Configure app-specific providers and normalize the legacy OpenRouter alias. */
328328
export async function setupModelRuntime(modelRuntime: ModelRuntime): Promise<void> {
329329
registerLocalProviders(modelRuntime);
330330

331-
// Pi reads every direct provider's key straight from process.env (loaded
332-
// from .env by env.ts), so only OpenRouter needs a push here — its legacy
333-
// OR_API_KEY alias is unknown to Pi.
334-
const orKey = process.env.OPENROUTER_API_KEY || process.env.OR_API_KEY;
335-
if (orKey) await modelRuntime.setRuntimeApiKey("openrouter", orKey);
331+
// Let Pi resolve stored credentials before ambient keys, consistently in
332+
// lead/child runtimes and after restart. A runtime override masks OAuth.
333+
// Normalize the legacy alias into the inherited environment for children.
334+
if (!process.env.OPENROUTER_API_KEY?.trim() && process.env.OR_API_KEY?.trim()) {
335+
process.env.OPENROUTER_API_KEY = process.env.OR_API_KEY.trim();
336+
await modelRuntime.refresh({ providers: ["openrouter"], allowNetwork: false });
337+
}
336338
}
337339

338340
export class ModelResolutionError extends Error {

‎server/src/agent/provider-auth.ts‎

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ import type {
66
AuthInteraction,
77
AuthPrompt,
88
CredentialInfo,
9+
LoginOptions,
910
Model,
1011
} from "@earendil-works/pi-ai";
11-
import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
12+
import { SettingsManager, type ModelRuntime } from "@earendil-works/pi-coding-agent";
13+
import { KADY_PI_AGENT_DIR } from "../config.ts";
1214
import type { DirectProviderBilling, DirectProviderDefinition } from "./provider-catalog.ts";
1315

1416
/**
@@ -350,10 +352,19 @@ function isTerminal(status: AuthFlowStatus): boolean {
350352
export class ProviderAuthManager {
351353
private readonly flows = new Map<string, AuthFlow>();
352354
private readonly activeByProvider = new Map<SubscriptionProviderId, string>();
355+
private authSettings?: SettingsManager;
353356

354357
constructor(
355358
private readonly runtime: ProviderAuthRuntime,
356359
private readonly flowTtlMs = DEFAULT_FLOW_TTL_MS,
360+
private readonly loginOptions: LoginOptions = {
361+
// Match Pi's interactive login: ChatGPT requires a stable, global
362+
// installation UUID. A project must never supply the installation ID.
363+
getDeviceId: () => {
364+
this.authSettings ??= SettingsManager.create(KADY_PI_AGENT_DIR, KADY_PI_AGENT_DIR);
365+
return this.authSettings.getOrCreateDeviceId();
366+
},
367+
},
357368
) {}
358369

359370
getRuntime(): ProviderAuthRuntime {
@@ -395,7 +406,8 @@ export class ProviderAuthManager {
395406
`${subscriptionProvider(providerId)?.name ?? providerId} is already connected`,
396407
);
397408
}
398-
if (configured.needsReauth) await this.runtime.logout(providerId);
409+
// Pi atomically replaces the stored credential on successful login.
410+
// Keep the previous credential if a reconnect is cancelled or fails.
399411
const activeId = this.activeByProvider.get(providerId);
400412
if (activeId) {
401413
const active = this.flows.get(activeId);
@@ -497,8 +509,7 @@ export class ProviderAuthManager {
497509
dispose(): void {
498510
for (const flow of this.flows.values()) {
499511
if (!isTerminal(flow.status)) {
500-
flow.controller.abort();
501-
flow.pending?.reject(new Error("Login cancelled"));
512+
this.cancel(flow.id);
502513
}
503514
if (flow.expiryTimer) clearTimeout(flow.expiryTimer);
504515
if (flow.removalTimer) clearTimeout(flow.removalTimer);
@@ -532,7 +543,7 @@ export class ProviderAuthManager {
532543
};
533544

534545
try {
535-
await this.runtime.login(flow.providerId, "oauth", interaction);
546+
await this.runtime.login(flow.providerId, "oauth", interaction, this.loginOptions);
536547
if (!isTerminal(flow.status)) {
537548
this.finish(flow, "complete");
538549
} else {
@@ -544,6 +555,7 @@ export class ProviderAuthManager {
544555
} catch (error) {
545556
if (!isTerminal(flow.status)) this.fail(flow, error);
546557
} finally {
558+
await this.authSettings?.flush();
547559
flow.running = false;
548560
flow.resolveSettled();
549561
if (this.activeByProvider.get(flow.providerId) === flow.id) {
@@ -583,7 +595,12 @@ export class ProviderAuthManager {
583595
settled = true;
584596
cleanup();
585597
if (flow.pending?.id === promptId) flow.pending = undefined;
586-
if (flow.prompt?.id === promptId) flow.prompt = undefined;
598+
if (flow.prompt?.id === promptId) {
599+
flow.prompt = undefined;
600+
// Pi cancels the manual prompt when the browser callback wins.
601+
if (!isTerminal(flow.status)) flow.status = "running";
602+
flow.updatedAt = Date.now();
603+
}
587604
reject(error);
588605
};
589606
const onAbort = () => settleReject(new Error("Login cancelled"));

‎server/src/agent/provider-catalog.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,8 @@ export interface DirectProviderDefinition {
7070
/** Unknown ids resolve to a synthesized $0 model instead of an error. */
7171
synthesizeUnknownIds: boolean;
7272
/**
73-
* Push the key into Pi's runtime credential on change (`setRuntimeApiKey`),
74-
* as the OpenRouter/NVIDIA rows always did. False for providers whose
73+
* Select environment-key auth on save by removing the previous stored Pi
74+
* credential. Never install a runtime-only override. False for providers whose
7575
* `resolve()` mixes several ambient sources — there process.env is the only
7676
* safe channel, and Pi reads it live.
7777
*/

0 commit comments

Comments
 (0)