Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/settings-manager.ts. Changed 273 characters to 799 characters.

     ...
 365 		}
 366 		this.globalSettings.terminal.showImages = show;
 367 		this.save();
 368 	}
+369
+370 	getApiKey(provider: string): string | undefined {
+371 		return this.settings.apiKeys?.[provider];
+372 	}
+373
+374 	setApiKey(provider: string, key: string): void {
+375 		if (!this.globalSettings.apiKeys) {
+376 			this.globalSettings.apiKeys = {};
+377 		}
+378 		this.globalSettings.apiKeys[provider] = key;
+379 		this.save();
+380 	}
+381
+382 	removeApiKey(provider: string): void {
+383 		if (this.globalSettings.apiKeys) {
+384 			delete this.globalSettings.apiKeys[provider];
+385 			this.save();
+386 		}
+387 	}
+388
+389 	getApiKeys(): Record<string, string> {
+390 		return this.settings.apiKeys ?? {};
+391 	}
 369 }


Now let me modify `defaultGetApiKey` in sdk.ts to check settings first:


}

// API Key Helpers

/**
 * Create the default API key resolver.
 * Checks custom providers (models.json), OAuth, and environment variables.
 */
export function defaultGetApiKey(): (model: Model<any>) => Promise<string | undefined> {
	return getApiKeyForModel;
}

// System Prompt

export interface BuildSystemPromptOptions {
	tools?: Tool[];
	skills?: Skill[];
	contextFiles?: Array<{ path: string; content: string }>;
	cwd?: string;
	appendPrompt?: string;
}

/**
 * Build the default system prompt.
 */
export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): string {
	return buildSystemPromptInternal({
		cwd: options.cwd,
		skills: options.skills,
		contextFiles: options.contextFiles,

[321 more lines in file. Use offset=355 to continue]


Now I see the issue. `defaultGetApiKey` doesn't have access to `settingsManager`. Let me check how it's used:


		thinkingLevel = settingsManager.getDefaultThinkingLevel() ?? "off";
	}

	// Clamp to model capabilities
	if (!model || !model.reasoning) {
		thinkingLevel = "off";
	}

	const getApiKey = options.getApiKey ?? defaultGetApiKey();

	const skills = options.skills ?? discoverSkills(cwd, agentDir, settingsManager.getSkillsSettings());
	time("discoverSkills");

	const contextFiles = options.contextFiles ?? discoverContextFiles(cwd, agentDir);
	time("discoverContextFiles");

	const builtInTools = options.tools ?? createCodingTools(cwd);
	time("createCodingTools");

	let customToolsResult: { tools: LoadedCustomTool[]; setUIContext: (ctx: any, hasUI: boolean) => void };

[116 more lines in file. Use offset=560 to continue]


So `settingsManager` is already available in `createAgentSession`. I can pass it to `defaultGetApiKey`:

edit

// API Key Helpers

/**
 * Create the default API key resolver.
 * Checks custom providers (models.json), OAuth, and environment variables.
 */
export function defaultGetApiKey(): (model: Model<any>) => Promise<string | undefined> {
	return getApiKeyForModel;
}

// API Key Helpers

/**
 * Create the default API key resolver.
 * Priority: settings.json apiKeys > custom providers (models.json) > OAuth > environment variables.
 */
export function defaultGetApiKey(
	settingsManager?: SettingsManager,
): (model: Model<any>) => Promise<string | undefined> {
	return async (model: Model<any>) => {
		// Check settings.json apiKeys first
		if (settingsManager) {
			const settingsKey = settingsManager.getApiKey(model.provider);
			if (settingsKey) {
				return settingsKey;
			}
		}
		// Fall back to existing resolution (custom providers, OAuth, env vars)
		return getApiKeyForModel(model);
	};
}


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/sdk.ts. Changed 261 characters to 633 characters.

     ...
 327 // API Key Helpers
 328
 329 /**
 330  * Create the default API key resolver.
-331  * Checks custom providers (models.json), OAuth, and environment variables.
+331  * Priority: settings.json apiKeys > custom providers (models.json) > OAuth > environment variables.
 332  */
-333 export function defaultGetApiKey(): (model: Model<any>) => Promise<string | undefined> {
-334 	return getApiKeyForModel;
+333 export function defaultGetApiKey(
+334 	settingsManager?: SettingsManager,
+335 ): (model: Model<any>) => Promise<string | undefined> {
+336 	return async (model: Model<any>) => {
+337 		// Check settings.json apiKeys first
+338 		if (settingsManager) {
+339 			const settingsKey = settingsManager.getApiKey(model.provider);
+340 			if (settingsKey) {
+341 				return settingsKey;
+342 			}
+343 		}
+344 		// Fall back to existing resolution (custom providers, OAuth, env vars)
+345 		return getApiKeyForModel(model);
+346 	};
 335 }
 336
 337 // System Prompt
 338
     ...


Now update the call site:

const getApiKey = options.getApiKey ?? defaultGetApiKey();

const getApiKey = options.getApiKey ?? defaultGetApiKey(settingsManager);


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/sdk.ts. Changed 59 characters to 74 characters.

     ...
 556 	if (!model || !model.reasoning) {
 557 		thinkingLevel = "off";
 558 	}
 559
-560 	const getApiKey = options.getApiKey ?? defaultGetApiKey();
+560 	const getApiKey = options.getApiKey ?? defaultGetApiKey(settingsManager);
 561
 562 	const skills = options.skills ?? discoverSkills(cwd, agentDir, settingsManager.getSkillsSettings());
 563 	time("discoverSkills");
 564
     ...


Now I need to check if `getApiKeyForModel` is also used elsewhere for model discovery (checking if models are available). Let me check:

bash

cd /Users/badlogic/workspaces/pi-mono && grep -n "getApiKeyForModel" packages/coding-agent/src/core/sdk.ts


45:	getApiKeyForModel,
345:		return getApiKeyForModel(model);
503:			const key = await getApiKeyForModel(restoredModel);
520:				const key = await getApiKeyForModel(settingsModel);


	// If session has data, try to restore model from it
	if (!model && hasExistingSession && existingSession.model) {
		const restoredModel = findModel(existingSession.model.provider, existingSession.model.modelId);
		if (restoredModel) {
			const key = await getApiKeyForModel(restoredModel);
			if (key) {
				model = restoredModel;
			}
		}
		if (!model) {
			modelFallbackMessage = `Could not restore model ${existingSession.model.provider}/${existingSession.model.modelId}`;
		}
	}

	// If still no model, try settings default
	if (!model) {
		const defaultProvider = settingsManager.getDefaultProvider();
		const defaultModelId = settingsManager.getDefaultModel();
		if (defaultProvider && defaultModelId) {
			const settingsModel = findModel(defaultProvider, defaultModelId);
			if (settingsModel) {
				const key = await getApiKeyForModel(settingsModel);
				if (key) {
					model = settingsModel;
				}
			}
		}
	}

	// Fall back to first available
	if (!model) {
		const available = await discoverAvailableModels();
		time("discoverAvailableModels");
		if (available.length > 0) {

[155 more lines in file. Use offset=533 to continue]


These calls to `getApiKeyForModel` are for model discovery (checking if a model has a valid key). They should also check settings first. Let me create a helper that wraps this:


 *   systemPrompt: 'You are helpful.',
 *   tools: [readTool, bashTool],
 *   hooks: [],
 *   skills: [],
 *   sessionManager: SessionManager.inMemory(),
 * });
 * ```
 */
export async function createAgentSession(options: CreateAgentSessionOptions = {}): Promise<CreateAgentSessionResult> {
	const cwd = options.cwd ?? process.cwd();
	const agentDir = options.agentDir ?? getDefaultAgentDir();

	// Configure OAuth storage for this agentDir
	configureOAuthStorage(agentDir);
	time("configureOAuthStorage");

	const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir);
	time("settingsManager");
	const sessionManager = options.sessionManager ?? SessionManager.create(cwd, agentDir);
	time("sessionManager");

	// Check if session has existing data to restore
	const existingSession = sessionManager.loadSession();
	time("loadSession");
	const hasExistingSession = existingSession.messages.length > 0;

	let model = options.model;
	let modelFallbackMessage: string | undefined;

	// If session has data, try to restore model from it
	if (!model && hasExistingSession && existingSession.model) {
		const restoredModel = findModel(existingSession.model.provider, existingSession.model.modelId);
		if (restoredModel) {
			const key = await getApiKeyForModel(restoredModel);
			if (key) {

[183 more lines in file. Use offset=505 to continue]


I should create a local helper function that checks settings first, then use it for model resolution:


export async function createAgentSession(options: CreateAgentSessionOptions = {}): Promise<CreateAgentSessionResult> {
	const cwd = options.cwd ?? process.cwd();
	const agentDir = options.agentDir ?? getDefaultAgentDir();

	// Configure OAuth storage for this agentDir
	configureOAuthStorage(agentDir);
	time("configureOAuthStorage");

	const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir);
	time("settingsManager");
	const sessionManager = options.sessionManager ?? SessionManager.create(cwd, agentDir);
	time("sessionManager");

	// Check if session has existing data to restore
	const existingSession = sessionManager.loadSession();
	time("loadSession");
	const hasExistingSession = existingSession.messages.length > 0;

	let model = options.model;
	let modelFallbackMessage: string | undefined;

	// If session has data, try to restore model from it
	if (!model && hasExistingSession && existingSession.model) {
		const restoredModel = findModel(existingSession.model.provider, existingSession.model.modelId);
		if (restoredModel) {
			const key = await getApiKeyForModel(restoredModel);
			if (key) {
				model = restoredModel;
			}
		}
		if (!model) {
			modelFallbackMessage = `Could not restore model ${existingSession.model.provider}/${existingSession.model.modelId}`;
		}
	}

	// If still no model, try settings default
	if (!model) {
		const defaultProvider = settingsManager.getDefaultProvider();
		const defaultModelId = settingsManager.getDefaultModel();
		if (defaultProvider && defaultModelId) {
			const settingsModel = findModel(defaultProvider, defaultModelId);
			if (settingsModel) {
				const key = await getApiKeyForModel(settingsModel);
				if (key) {
					model = settingsModel;
				}
			}
		}
	}

	// Fall back to first available
	if (!model) {
		const available = await discoverAvailableModels();
		time("discoverAvailableModels");
		if (available.length > 0) {
			model = available[0];
			if (modelFallbackMessage) {
				modelFallbackMessage += `. Using ${model.provider}/${model.id}`;
			}
		} else {

[150 more lines in file. Use offset=538 to continue]


edit

export async function createAgentSession(options: CreateAgentSessionOptions = {}): Promise<CreateAgentSessionResult> {
	const cwd = options.cwd ?? process.cwd();
	const agentDir = options.agentDir ?? getDefaultAgentDir();

	// Configure OAuth storage for this agentDir
	configureOAuthStorage(agentDir);
	time("configureOAuthStorage");

	const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir);
	time("settingsManager");
	const sessionManager = options.sessionManager ?? SessionManager.create(cwd, agentDir);
	time("sessionManager");

	// Check if session has existing data to restore
	const existingSession = sessionManager.loadSession();
	time("loadSession");
	const hasExistingSession = existingSession.messages.length > 0;

	let model = options.model;
	let modelFallbackMessage: string | undefined;

	// If session has data, try to restore model from it
	if (!model && hasExistingSession && existingSession.model) {
		const restoredModel = findModel(existingSession.model.provider, existingSession.model.modelId);
		if (restoredModel) {
			const key = await getApiKeyForModel(restoredModel);
			if (key) {
				model = restoredModel;
			}
		}
		if (!model) {
			modelFallbackMessage = `Could not restore model ${existingSession.model.provider}/${existingSession.model.modelId}`;
		}
	}

	// If still no model, try settings default
	if (!model) {
		const defaultProvider = settingsManager.getDefaultProvider();
		const defaultModelId = settingsManager.getDefaultModel();
		if (defaultProvider && defaultModelId) {
			const settingsModel = findModel(defaultProvider, defaultModelId);
			if (settingsModel) {
				const key = await getApiKeyForModel(settingsModel);
				if (key) {
					model = settingsModel;
				}
			}
		}
	}

export async function createAgentSession(options: CreateAgentSessionOptions = {}): Promise<CreateAgentSessionResult> {
	const cwd = options.cwd ?? process.cwd();
	const agentDir = options.agentDir ?? getDefaultAgentDir();

	// Configure OAuth storage for this agentDir
	configureOAuthStorage(agentDir);
	time("configureOAuthStorage");

	const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir);
	time("settingsManager");
	const sessionManager = options.sessionManager ?? SessionManager.create(cwd, agentDir);
	time("sessionManager");

	// Helper to check API key availability (settings first, then OAuth/env vars)
	const hasApiKey = async (m: Model<any>): Promise<boolean> => {
		const settingsKey = settingsManager.getApiKey(m.provider);
		if (settingsKey) return true;
		return !!(await getApiKeyForModel(m));
	};

	// Check if session has existing data to restore
	const existingSession = sessionManager.loadSession();
	time("loadSession");
	const hasExistingSession = existingSession.messages.length > 0;

	let model = options.model;
	let modelFallbackMessage: string | undefined;

	// If session has data, try to restore model from it
	if (!model && hasExistingSession && existingSession.model) {
		const restoredModel = findModel(existingSession.model.provider, existingSession.model.modelId);
		if (restoredModel && (await hasApiKey(restoredModel))) {
			model = restoredModel;
		}
		if (!model) {
			modelFallbackMessage = `Could not restore model ${existingSession.model.provider}/${existingSession.model.modelId}`;
		}
	}

	// If still no model, try settings default
	if (!model) {
		const defaultProvider = settingsManager.getDefaultProvider();
		const defaultModelId = settingsManager.getDefaultModel();
		if (defaultProvider && defaultModelId) {
			const settingsModel = findModel(defaultProvider, defaultModelId);
			if (settingsModel && (await hasApiKey(settingsModel))) {
				model = settingsModel;
			}
		}
	}


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/sdk.ts. Changed 1757 characters to 1958 characters.

     ...
 487 	time("settingsManager");
 488 	const sessionManager = options.sessionManager ?? SessionManager.create(cwd, agentDir);
 489 	time("sessionManager");
 490
+491 	// Helper to check API key availability (settings first, then OAuth/env vars)
+492 	const hasApiKey = async (m: Model<any>): Promise<boolean> => {
+493 		const settingsKey = settingsManager.getApiKey(m.provider);
+494 		if (settingsKey) return true;
+495 		return !!(await getApiKeyForModel(m));
+496 	};
+497
 491 	// Check if session has existing data to restore
 492 	const existingSession = sessionManager.loadSession();
 493 	time("loadSession");
 494 	const hasExistingSession = existingSession.messages.length > 0;
 495
 496 	let model = options.model;
 497 	let modelFallbackMessage: string | undefined;
 498
 499 	// If session has data, try to restore model from it
 500 	if (!model && hasExistingSession && existingSession.model) {
 501 		const restoredModel = findModel(existingSession.model.provider, existingSession.model.modelId);
-502 		if (restoredModel) {
-503 			const key = await getApiKeyForModel(restoredModel);
-504 			if (key) {
-505 				model = restoredModel;
-506 			}
+509 		if (restoredModel && (await hasApiKey(restoredModel))) {
+510 			model = restoredModel;
 507 		}
 508 		if (!model) {
 509 			modelFallbackMessage = `Could not restore model ${existingSession.model.provider}/${existingSession.model.modelId}`;
 510 		}
 511 	}
 512
 513 	// If still no model, try settings default
 514 	if (!model) {
 515 		const defaultProvider = settingsManager.getDefaultProvider();
 516 		const defaultModelId = settingsManager.getDefaultModel();
 517 		if (defaultProvider && defaultModelId) {
 518 			const settingsModel = findModel(defaultProvider, defaultModelId);
-519 			if (settingsModel) {
-520 				const key = await getApiKeyForModel(settingsModel);
-521 				if (key) {
-522 					model = settingsModel;
-523 				}
+523 			if (settingsModel && (await hasApiKey(settingsModel))) {
+524 				model = settingsModel;
 524 			}
 525 		}
 526 	}
 527
     ...


Now I need to also make `discoverAvailableModels` aware of settings API keys. Let me check where that function is:

bash

cd /Users/badlogic/workspaces/pi-mono && grep -n "discoverAvailableModels" packages/coding-agent/src/core/sdk.ts


225:export async function discoverAvailableModels(agentDir: string = getDefaultAgentDir()): Promise<Model<any>[]> {
531:		const available = await discoverAvailableModels();
532:		time("discoverAvailableModels");


}

/**
 * Get models that have valid API keys available.
 */
export async function discoverAvailableModels(agentDir: string = getDefaultAgentDir()): Promise<Model<any>[]> {
	const { models, error } = await getAvailableModels(agentDir);
	if (error) {
		throw new Error(error);
	}
	return models;
}

/**
 * Find a model by provider and ID.
 * @returns The model, or null if not found
 */
export function findModel(
	provider: string,
	modelId: string,
	agentDir: string = getDefaultAgentDir(),
): Model<any> | null {
	const { model, error } = findModelInternal(provider, modelId, agentDir);
	if (error) {
		throw new Error(error);
	}
	return model;
}

/**

[439 more lines in file. Use offset=250 to continue]


The issue is that `discoverAvailableModels` calls `getAvailableModels` from `model-config.ts`, which uses `getApiKeyForModel` directly. To make this work properly, I need to either:

1. Pass settingsManager through to `discoverAvailableModels` and down to `getAvailableModels`
2. Or use a different approach for checking available models in `createAgentSession`

Let me check how `getAvailableModels` works:


 * Returns { models, error } - either models array or error message
 */
export async function getAvailableModels(
	agentDir: string = getAgentDir(),
): Promise<{ models: Model<Api>[]; error: string | null }> {
	const { models: allModels, error } = loadAndMergeModels(agentDir);

	if (error) {
		return { models: [], error };
	}

	const availableModels: Model<Api>[] = [];
	for (const model of allModels) {
		const apiKey = await getApiKeyForModel(model);
		if (apiKey) {
			availableModels.push(model);
		}
	}

	return { models: availableModels, error: null };
}

/**
 * Find a specific model by provider and ID
 * Returns { model, error } - either model or error message
 */
export function findModel(
	provider: string,
	modelId: string,
	agentDir: string = getAgentDir(),
): { model: Model<Api> | null; error: string | null } {
	const { models: allModels, error } = loadAndMergeModels(agentDir);

	if (error) {
		return { model: null, error };
	}

	const model = allModels.find((m) => m.provider === provider && m.id === modelId) || null;
	return { model, error: null };
}

[56 more lines in file. Use offset=400 to continue]


I think the cleanest approach here is to add an optional `apiKeyResolver` parameter to both `discoverAvailableModels` and `getAvailableModels`. But that's more invasive.

A simpler approach: since we already have `hasApiKey` helper in `createAgentSession`, let me just get all models and filter them there instead of using `discoverAvailableModels`:

cd /Users/badlogic/workspaces/pi-mono && grep -n "loadAndMergeModels\|getAllModels" packages/coding-agent/src/core/sdk.ts


47:	loadAndMergeModels,
215:	const { models, error } = loadAndMergeModels(agentDir);


/**
 * Get all models (built-in + custom from models.json).
 */
export function discoverModels(agentDir: string = getDefaultAgentDir()): Model<any>[] {
	const { models, error } = loadAndMergeModels(agentDir);
	if (error) {
		throw new Error(error);
	}
	return models;
}

/**
 * Get models that have valid API keys available.
 */
export async function discoverAvailableModels(agentDir: string = getDefaultAgentDir()): Promise<Model<any>[]> {
	const { models, error } = await getAvailableModels(agentDir);
	if (error) {
		throw new Error(error);
	}
	return models;
}

/**
 * Find a model by provider and ID.

[454 more lines in file. Use offset=235 to continue]


Let me update the code to use `discoverModels` and filter with `hasApiKey`:


			}
		}
	}

	// Fall back to first available
	if (!model) {
		const available = await discoverAvailableModels();
		time("discoverAvailableModels");
		if (available.length > 0) {
			model = available[0];
			if (modelFallbackMessage) {
				modelFallbackMessage += `. Using ${model.provider}/${model.id}`;
			}
		} else {
			// No models available - set message so user knows to /login or configure keys
			modelFallbackMessage = "No models available. Use /login or set an API key environment variable.";
		}
	}

	let thinkingLevel = options.thinkingLevel;

	// If session has data, restore thinking level from it
	if (thinkingLevel === undefined && hasExistingSession) {
		thinkingLevel = existingSession.thinkingLevel as ThinkingLevel;
	}

[139 more lines in file. Use offset=550 to continue]


edit

	// Fall back to first available
	if (!model) {
		const available = await discoverAvailableModels();
		time("discoverAvailableModels");
		if (available.length > 0) {
			model = available[0];
			if (modelFallbackMessage) {
				modelFallbackMessage += `. Using ${model.provider}/${model.id}`;
			}
		} else {
			// No models available - set message so user knows to /login or configure keys
			modelFallbackMessage = "No models available. Use /login or set an API key environment variable.";
		}
	}

	// Fall back to first available model with a valid API key
	if (!model) {
		const allModels = discoverModels(agentDir);
		for (const m of allModels) {
			if (await hasApiKey(m)) {
				model = m;
				break;
			}
		}
		time("discoverAvailableModels");
		if (model) {
			if (modelFallbackMessage) {
				modelFallbackMessage += `. Using ${model.provider}/${model.id}`;
			}
		} else {
			// No models available - set message so user knows to /login or configure keys
			modelFallbackMessage = "No models available. Use /login or set an API key environment variable.";
		}
	}


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/sdk.ts. Changed 496 characters to 571 characters.

     ...
 525 			}
 526 		}
 527 	}
 528
-529 	// Fall back to first available
+529 	// Fall back to first available model with a valid API key
 530 	if (!model) {
-531 		const available = await discoverAvailableModels();
+531 		const allModels = discoverModels(agentDir);
+532 		for (const m of allModels) {
+533 			if (await hasApiKey(m)) {
+534 				model = m;
+535 				break;
+536 			}
+537 		}
 532 		time("discoverAvailableModels");
-533 		if (available.length > 0) {
-534 			model = available[0];
+539 		if (model) {
 535 			if (modelFallbackMessage) {
 536 				modelFallbackMessage += `. Using ${model.provider}/${model.id}`;
 537 			}
 538 		} else {
     ...


Now let's run the check:

bash

cd /Users/badlogic/workspaces/pi-mono && npm run check 2>&1


> pi-monorepo@0.0.3 check
> biome check --write . && npm run check --workspaces --if-present && tsgo --noEmit

Checked 284 files in 153ms. No fixes applied.

> @mariozechner/pi-agent-core@0.27.2 check
> tsgo --noEmit

> @mariozechner/pi-ai@0.27.2 check
> biome check --write . && tsgo --noEmit

Checked 44 files in 42ms. No fixes applied.

> @mariozechner/pi-coding-agent@0.27.2 check
> tsgo --noEmit && tsgo -p tsconfig.examples.json

> @mariozechner/pi-mom@0.27.2 check
> biome check --write . && tsgo --noEmit

Checked 16 files in 18ms. No fixes applied.

> @mariozechner/pi@0.27.2 check
> biome check --write .

Checked 10 files in 17ms. No fixes applied.

> @mariozechner/pi-proxy@0.27.2 check
> biome check --write .

Checked 3 files in 4ms. No fixes applied.

> @mariozechner/pi-tui@0.27.2 check
> biome check --write . && tsgo --noEmit

Checked 27 files in 25ms. No fixes applied.

> @mariozechner/pi-web-ui@0.27.2 check
> tsgo --noEmit && cd example && tsgo --noEmit

> pi-web-ui-example@1.15.2 check
> tsgo --noEmit


Let me also export `SettingsManager` type from settings-manager.ts if needed, and check if the type is exported:

cd /Users/badlogic/workspaces/pi-mono && grep -n "export.*SettingsManager" packages/coding-agent/src/core/settings-manager.ts


84:export class SettingsManager {


Good, it's exported. Now let me add the changelog entry:


# Changelog

## [Unreleased]

### Fixed

- **Allow startup without API keys**: Interactive mode no longer throws when no API keys are configured. Users can now start the agent and use `/login` to authenticate. ([#288](https://github.com/badlogic/pi-mono/issues/288))
- **`--system-prompt` file path support**: The `--system-prompt` argument now correctly resolves file paths (like `--append-system-prompt` already did). ([#287](https://github.com/badlogic/pi-mono/pull/287) by [@scutifer](https://github.com/scutifer))

## [0.27.2] - 2025-12-23

### Added

- **Skip conversation restore on branch**: Hooks can return `{ skipConversationRestore: true }` from `before_branch` to create the branched session file without restoring conversation messages. Useful for checkpoint hooks that restore files separately. ([#286](https://github.com/badlogic/pi-mono/pull/286) by [@nicobarray](https://github.com/nicobarray))

[807 more lines in file. Use offset=16 to continue]


edit

# Changelog

## [Unreleased]

### Fixed

- **Allow startup without API keys**: Interactive mode no longer throws when no API keys are configured. Users can now start the agent and use `/login` to authenticate. ([#288](https://github.com/badlogic/pi-mono/issues/288))
- **`--system-prompt` file path support**: The `--system-prompt` argument now correctly resolves file paths (like `--append-system-prompt` already did). ([#287](https://github.com/badlogic/pi-mono/pull/287) by [@scutifer](https://github.com/scutifer))

# Changelog

## [Unreleased]

### Added

- **API keys in settings.json**: Store API keys in `~/.pi/agent/settings.json` under the `apiKeys` field (e.g., `{ "apiKeys": { "anthropic": "sk-..." } }`). Settings keys take priority over environment variables. ([#295](https://github.com/badlogic/pi-mono/issues/295))

### Fixed

- **Allow startup without API keys**: Interactive mode no longer throws when no API keys are configured. Users can now start the agent and use `/login` to authenticate. ([#288](https://github.com/badlogic/pi-mono/issues/288))
- **`--system-prompt` file path support**: The `--system-prompt` argument now correctly resolves file paths (like `--append-system-prompt` already did). ([#287](https://github.com/badlogic/pi-mono/pull/287) by [@scutifer](https://github.com/scutifer))


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/CHANGELOG.md. Changed 518 characters to 800 characters.

   1 # Changelog
   2
   3 ## [Unreleased]
   4
+  5 ### Added
+  6
+  7 - **API keys in settings.json**: Store API keys in `~/.pi/agent/settings.json` under the `apiKeys` field (e.g., `{ "apiKeys": { "anthropic": "sk-..." } }`). Settings keys take priority over environment variables. ([#295](https://github.com/badlogic/pi-mono/issues/295))
+  8
   5 ### Fixed
   6
   7 - **Allow startup without API keys**: Interactive mode no longer throws when no API keys are configured. Users can now start the agent and use `/login` to authenticate. ([#288](https://github.com/badlogic/pi-mono/issues/288))
   8 - **`--system-prompt` file path support**: The `--system-prompt` argument now correctly resolves file paths (like `--append-system-prompt` already did). ([#287](https://github.com/badlogic/pi-mono/pull/287) by [@scutifer](https://github.com/scutifer))
     ...


Let me verify the changes look correct:

bash

cd /Users/badlogic/workspaces/pi-mono && git diff --stat


 packages/coding-agent/CHANGELOG.md                 |  4 ++
 packages/coding-agent/src/core/sdk.ts              | 54 ++++++++++++++--------
 packages/coding-agent/src/core/settings-manager.ts | 24 ++++++++++
 3 files changed, 64 insertions(+), 18 deletions(-)


cd /Users/badlogic/workspaces/pi-mono && git diff packages/coding-agent/src/core/settings-manager.ts


diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts
index e56b277..ff0e801 100644
--- a/packages/coding-agent/src/core/settings-manager.ts
+++ b/packages/coding-agent/src/core/settings-manager.ts
@@ -47,6 +47,7 @@ export interface Settings {
 	customTools?: string[]; // Array of custom tool file paths
 	skills?: SkillsSettings;
 	terminal?: TerminalSettings;
+	apiKeys?: Record<string, string>; // provider -> API key (e.g., { "anthropic": "sk-..." })
 }

 /** Deep merge settings: project/overrides take precedence, nested objects merge recursively */
@@ -365,4 +366,27 @@ export class SettingsManager {
 		this.globalSettings.terminal.showImages = show;
 		this.save();
 	}
+
+	getApiKey(provider: string): string | undefined {
+		return this.settings.apiKeys?.[provider];
+	}
+
+	setApiKey(provider: string, key: string): void {
+		if (!this.globalSettings.apiKeys) {
+			this.globalSettings.apiKeys = {};
+		}
+		this.globalSettings.apiKeys[provider] = key;
+		this.save();
+	}
+
+	removeApiKey(provider: string): void {
+		if (this.globalSettings.apiKeys) {
+			delete this.globalSettings.apiKeys[provider];
+			this.save();
+		}
+	}
+
+	getApiKeys(): Record<string, string> {
+		return this.settings.apiKeys ?? {};
+	}
 }


cd /Users/badlogic/workspaces/pi-mono && git diff packages/coding-agent/src/core/sdk.ts


diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts
index 2bbb6e8..e2b3a81 100644
--- a/packages/coding-agent/src/core/sdk.ts
+++ b/packages/coding-agent/src/core/sdk.ts
@@ -328,10 +328,22 @@ export function discoverSlashCommands(cwd?: string, agentDir?: string): FileSlas

 /**
  * Create the default API key resolver.
- * Checks custom providers (models.json), OAuth, and environment variables.
+ * Priority: settings.json apiKeys > custom providers (models.json) > OAuth > environment variables.
  */
-export function defaultGetApiKey(): (model: Model<any>) => Promise<string | undefined> {
-	return getApiKeyForModel;
+export function defaultGetApiKey(
+	settingsManager?: SettingsManager,
+): (model: Model<any>) => Promise<string | undefined> {
+	return async (model: Model<any>) => {
+		// Check settings.json apiKeys first
+		if (settingsManager) {
+			const settingsKey = settingsManager.getApiKey(model.provider);
+			if (settingsKey) {
+				return settingsKey;
+			}
+		}
+		// Fall back to existing resolution (custom providers, OAuth, env vars)
+		return getApiKeyForModel(model);
+	};
 }

 // System Prompt
@@ -476,6 +488,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
 	const sessionManager = options.sessionManager ?? SessionManager.create(cwd, agentDir);
 	time("sessionManager");

+	// Helper to check API key availability (settings first, then OAuth/env vars)
+	const hasApiKey = async (m: Model<any>): Promise<boolean> => {
+		const settingsKey = settingsManager.getApiKey(m.provider);
+		if (settingsKey) return true;
+		return !!(await getApiKeyForModel(m));
+	};
+
 	// Check if session has existing data to restore
 	const existingSession = sessionManager.loadSession();
 	time("loadSession");
@@ -487,11 +506,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
 	// If session has data, try to restore model from it
 	if (!model && hasExistingSession && existingSession.model) {
 		const restoredModel = findModel(existingSession.model.provider, existingSession.model.modelId);
-		if (restoredModel) {
-			const key = await getApiKeyForModel(restoredModel);
-			if (key) {
-				model = restoredModel;
-			}
+		if (restoredModel && (await hasApiKey(restoredModel))) {
+			model = restoredModel;
 		}
 		if (!model) {
 			modelFallbackMessage = `Could not restore model ${existingSession.model.provider}/${existingSession.model.modelId}`;
@@ -504,21 +520,23 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
 		const defaultModelId = settingsManager.getDefaultModel();
 		if (defaultProvider && defaultModelId) {
 			const settingsModel = findModel(defaultProvider, defaultModelId);
-			if (settingsModel) {
-				const key = await getApiKeyForModel(settingsModel);
-				if (key) {
-					model = settingsModel;
-				}
+			if (settingsModel && (await hasApiKey(settingsModel))) {
+				model = settingsModel;
 			}
 		}
 	}

-	// Fall back to first available
+	// Fall back to first available model with a valid API key
 	if (!model) {
-		const available = await discoverAvailableModels();
+		const allModels = discoverModels(agentDir);
+		for (const m of allModels) {
+			if (await hasApiKey(m)) {
+				model = m;
+				break;
+			}
+		}
 		time("discoverAvailableModels");
-		if (available.length > 0) {
-			model = available[0];
+		if (model) {
 			if (modelFallbackMessage) {
 				modelFallbackMessage += `. Using ${model.provider}/${model.id}`;
 			}
@@ -545,7 +563,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
 		thinkingLevel = "off";
 	}

-	const getApiKey = options.getApiKey ?? defaultGetApiKey();
+	const getApiKey = options.getApiKey ?? defaultGetApiKey(settingsManager);

 	const skills = options.skills ?? discoverSkills(cwd, agentDir, settingsManager.getSkillsSettings());
 	time("discoverSkills");


The changes look good. Let me run the check once more to make sure everything is fine:


> pi-monorepo@0.0.3 check
> biome check --write . && npm run check --workspaces --if-present && tsgo --noEmit

Checked 284 files in 151ms. No fixes applied.

> @mariozechner/pi-agent-core@0.27.2 check
> tsgo --noEmit

> @mariozechner/pi-ai@0.27.2 check
> biome check --write . && tsgo --noEmit

Checked 44 files in 43ms. No fixes applied.

> @mariozechner/pi-coding-agent@0.27.2 check
> tsgo --noEmit && tsgo -p tsconfig.examples.json

> @mariozechner/pi-mom@0.27.2 check
> biome check --write . && tsgo --noEmit

Checked 16 files in 16ms. No fixes applied.

> @mariozechner/pi@0.27.2 check
> biome check --write .

Checked 10 files in 17ms. No fixes applied.

> @mariozechner/pi-proxy@0.27.2 check
> biome check --write .

Checked 3 files in 3ms. No fixes applied.

> @mariozechner/pi-tui@0.27.2 check
> biome check --write . && tsgo --noEmit

Checked 27 files in 31ms. No fixes applied.

> @mariozechner/pi-web-ui@0.27.2 check
> tsgo --noEmit && cd example && tsgo --noEmit

> pi-web-ui-example@1.15.2 check
> tsgo --noEmit


All checks pass. Here's a summary of the changes:

**Summary:**

1. **`settings-manager.ts`**: Added `apiKeys?: Record<string, string>` to `Settings` interface, plus getter/setter methods (`getApiKey`, `setApiKey`, `removeApiKey`, `getApiKeys`)

2. **`sdk.ts`**:
   - Modified `defaultGetApiKey()` to accept optional `SettingsManager` and check settings API keys first before falling back to `getApiKeyForModel`
   - Added `hasApiKey` helper in `createAgentSession` that checks settings first
   - Updated model discovery to use `hasApiKey` helper (for session restore, settings default, and fallback)
   - Passed `settingsManager` to `defaultGetApiKey()`

**Priority order for API keys:**
1. `settings.json` apiKeys
2. Custom providers (models.json)
3. OAuth credentials
4. Environment variables

Users can now add API keys to `~/.pi/agent/settings.json`:
```json
{
  "apiKeys": {
    "anthropic": "sk-ant-...",
    "openai": "sk-..."
  }
}
```


well, it's unfortunate that we have to put settingsManager into defaultGetApiKey, but i see no better solution for now. we need to also update the api key seection in sdk.md


# SDK

The SDK provides programmatic access to pi's agent capabilities. Use it to embed pi in other applications, build custom interfaces, or integrate with automated workflows.

**Example use cases:**
- Build a custom UI (web, desktop, mobile)
- Integrate agent capabilities into existing applications
- Create automated pipelines with agent reasoning
- Build custom tools that spawn sub-agents
- Test agent behavior programmatically

See [examples/sdk/](../examples/sdk/) for working examples from minimal to full control.

## Quick Start

```typescript
import { createAgentSession, SessionManager } from "@mariozechner/pi-coding-agent";

const { session } = await createAgentSession({
  sessionManager: SessionManager.inMemory(),
});

session.subscribe((event) => {
  if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
    process.stdout.write(event.assistantMessageEvent.delta);
  }
});

await session.prompt("What files are in the current directory?");
```

## Installation

```bash
npm install @mariozechner/pi-coding-agent
```

The SDK is included in the main package. No separate installation needed.

## Core Concepts

### createAgentSession()

The main factory function. Creates an `AgentSession` with configurable options.

**Philosophy:** "Omit to discover, provide to override."
- Omit an option → pi discovers/loads from standard locations
- Provide an option → your value is used, discovery skipped for that option

```typescript

[815 more lines in file. Use offset=51 to continue]


cd /Users/badlogic/workspaces/pi-mono && grep -n "API Key\|api.*key\|getApiKey" packages/coding-agent/docs/sdk.md


259:### API Keys
269:  getApiKey: async (model) => {
287:> See [examples/sdk/09-api-keys-and-oauth.ts](../examples/sdk/09-api-keys-and-oauth.ts)
714:const getApiKey = async (model: { provider: string }) => {
757:  getApiKey,


3. Falls back to first available model

> See [examples/sdk/02-custom-model.ts](../examples/sdk/02-custom-model.ts)

### API Keys

```typescript
import { defaultGetApiKey, configureOAuthStorage } from "@mariozechner/pi-coding-agent";

// Default: checks models.json, OAuth, environment variables
const { session } = await createAgentSession();

// Custom resolver
const { session } = await createAgentSession({
  getApiKey: async (model) => {
    // Custom logic (secrets manager, database, etc.)
    if (model.provider === "anthropic") {
      return process.env.MY_ANTHROPIC_KEY;
    }
    // Fall back to default
    return defaultGetApiKey()(model);
  },
});

// Use OAuth from ~/.pi/agent with custom agentDir for everything else
configureOAuthStorage(); // Must call before createAgentSession
const { session } = await createAgentSession({
  agentDir: "/custom/config",
  // OAuth tokens still come from ~/.pi/agent/oauth.json
});
```

> See [examples/sdk/09-api-keys-and-oauth.ts](../examples/sdk/09-api-keys-and-oauth.ts)

### System Prompt

```typescript
const { session } = await createAgentSession({
  // Replace entirely
  systemPrompt: "You are a helpful assistant.",

  // Or modify default (receives default, returns modified)
  systemPrompt: (defaultPrompt) => {
    return `${defaultPrompt}\n\n## Additional Rules\n- Be concise`;
  },

[566 more lines in file. Use offset=300 to continue]


edit

### API Keys

```typescript
import { defaultGetApiKey, configureOAuthStorage } from "@mariozechner/pi-coding-agent";

// Default: checks models.json, OAuth, environment variables
const { session } = await createAgentSession();

// Custom resolver
const { session } = await createAgentSession({
  getApiKey: async (model) => {
    // Custom logic (secrets manager, database, etc.)
    if (model.provider === "anthropic") {
      return process.env.MY_ANTHROPIC_KEY;
    }
    // Fall back to default
    return defaultGetApiKey()(model);
  },
});

// Use OAuth from ~/.pi/agent with custom agentDir for everything else
configureOAuthStorage(); // Must call before createAgentSession
const { session } = await createAgentSession({
  agentDir: "/custom/config",
  // OAuth tokens still come from ~/.pi/agent/oauth.json
});
```

> See [examples/sdk/09-api-keys-and-oauth.ts](../examples/sdk/09-api-keys-and-oauth.ts)

### API Keys

API key resolution priority:
1. `settings.json` apiKeys (e.g., `{ "apiKeys": { "anthropic": "sk-..." } }`)
2. Custom providers from `models.json`
3. OAuth credentials from `oauth.json`
4. Environment variables (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.)

```typescript
import { defaultGetApiKey, configureOAuthStorage } from "@mariozechner/pi-coding-agent";

// Default: checks settings.json, models.json, OAuth, environment variables
const { session } = await createAgentSession();

// Custom resolver
const { session } = await createAgentSession({
  getApiKey: async (model) => {
    // Custom logic (secrets manager, database, etc.)
    if (model.provider === "anthropic") {
      return process.env.MY_ANTHROPIC_KEY;
    }
    // Fall back to default (pass settingsManager for settings.json lookup)
    return defaultGetApiKey()(model);
  },
});

// Use OAuth from ~/.pi/agent with custom agentDir for everything else
configureOAuthStorage(); // Must call before createAgentSession
const { session } = await createAgentSession({
  agentDir: "/custom/config",
  // OAuth tokens still come from ~/.pi/agent/oauth.json
});
```

> See [examples/sdk/09-api-keys-and-oauth.ts](../examples/sdk/09-api-keys-and-oauth.ts)


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/sdk.md. Changed 912 characters to 1232 characters.

     ...
 257 > See [examples/sdk/02-custom-model.ts](../examples/sdk/02-custom-model.ts)
 258
 259 ### API Keys
 260
+261 API key resolution priority:
+262 1. `settings.json` apiKeys (e.g., `{ "apiKeys": { "anthropic": "sk-..." } }`)
+263 2. Custom providers from `models.json`
+264 3. OAuth credentials from `oauth.json`
+265 4. Environment variables (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.)
+266
 261 ```typescript
 262 import { defaultGetApiKey, configureOAuthStorage } from "@mariozechner/pi-coding-agent";
 263
-264 // Default: checks models.json, OAuth, environment variables
+270 // Default: checks settings.json, models.json, OAuth, environment variables
 265 const { session } = await createAgentSession();
 266
 267 // Custom resolver
 268 const { session } = await createAgentSession({
 269   getApiKey: async (model) => {
 270     // Custom logic (secrets manager, database, etc.)
 271     if (model.provider === "anthropic") {
 272       return process.env.MY_ANTHROPIC_KEY;
 273     }
-274     // Fall back to default
+280     // Fall back to default (pass settingsManager for settings.json lookup)
 275     return defaultGetApiKey()(model);
 276   },
 277 });
 278
     ...


> pi-monorepo@0.0.3 check
> biome check --write . && npm run check --workspaces --if-present && tsgo --noEmit

Checked 284 files in 152ms. No fixes applied.

> @mariozechner/pi-agent-core@0.27.2 check
> tsgo --noEmit

> @mariozechner/pi-ai@0.27.2 check
> biome check --write . && tsgo --noEmit

Checked 44 files in 41ms. No fixes applied.

> @mariozechner/pi-coding-agent@0.27.2 check
> tsgo --noEmit && tsgo -p tsconfig.examples.json

> @mariozechner/pi-mom@0.27.2 check
> biome check --write . && tsgo --noEmit

Checked 16 files in 18ms. No fixes applied.

> @mariozechner/pi@0.27.2 check
> biome check --write .

Checked 10 files in 17ms. No fixes applied.

> @mariozechner/pi-proxy@0.27.2 check
> biome check --write .

Checked 3 files in 4ms. No fixes applied.

> @mariozechner/pi-tui@0.27.2 check
> biome check --write . && tsgo --noEmit

Checked 27 files in 25ms. No fixes applied.

> @mariozechner/pi-web-ui@0.27.2 check
> tsgo --noEmit && cd example && tsgo --noEmit

> pi-web-ui-example@1.15.2 check
> tsgo --noEmit


Done. Updated the SDK docs to document the API key resolution priority.


ok comimt and push close the related issue via "fixed #issue"


bash

cd /Users/badlogic/workspaces/pi-mono && git add -A && git commit -m "Add API keys in settings.json, fixes #295" && git push


Running formatting, linting, and type checking...

> pi-monorepo@0.0.3 check
> biome check --write . && npm run check --workspaces --if-present && tsgo --noEmit

Checked 284 files in 155ms. No fixes applied.

> @mariozechner/pi-agent-core@0.27.2 check
> tsgo --noEmit

> @mariozechner/pi-ai@0.27.2 check
> biome check --write . && tsgo --noEmit

Checked 44 files in 42ms. No fixes applied.

> @mariozechner/pi-coding-agent@0.27.2 check
> tsgo --noEmit && tsgo -p tsconfig.examples.json

> @mariozechner/pi-mom@0.27.2 check
> biome check --write . && tsgo --noEmit

Checked 16 files in 17ms. No fixes applied.

> @mariozechner/pi@0.27.2 check
> biome check --write .

Checked 10 files in 17ms. No fixes applied.

> @mariozechner/pi-proxy@0.27.2 check
> biome check --write .

Checked 3 files in 3ms. No fixes applied.

> @mariozechner/pi-tui@0.27.2 check
> biome check --write . && tsgo --noEmit

Checked 27 files in 25ms. No fixes applied.

> @mariozechner/pi-web-ui@0.27.2 check
> tsgo --noEmit && cd example && tsgo --noEmit

> pi-web-ui-example@1.15.2 check
> tsgo --noEmit

✅ All pre-commit checks passed!
[main bb1da1e] Add API keys in settings.json, fixes #295
 4 files changed, 72 insertions(+), 20 deletions(-)
To https://github.com/badlogic/pi-mono.git
   e234e8d..bb1da1e  main -> main


Done. Committed and pushed. The issue #295 will be automatically closed.


i think README.md of coding-agent also needs an update?


cd /Users/badlogic/workspaces/pi-mono && grep -n "API\|api.*key\|ANTHROPIC\|OPENAI" packages/coding-agent/README.md | head -30


12:  - [API Keys & OAuth](#api-keys--oauth)
107:### API Keys & OAuth
113:| Anthropic | `ANTHROPIC_API_KEY` |
114:| OpenAI | `OPENAI_API_KEY` |
115:| Google | `GEMINI_API_KEY` |
116:| Mistral | `MISTRAL_API_KEY` |
117:| Groq | `GROQ_API_KEY` |
118:| Cerebras | `CEREBRAS_API_KEY` |
119:| xAI | `XAI_API_KEY` |
120:| OpenRouter | `OPENROUTER_API_KEY` |
121:| ZAI | `ZAI_API_KEY` |
153:export ANTHROPIC_API_KEY=sk-ant-...
300:**Manual:** `/compact` or `/compact Focus on the API changes`
377:      "apiKey": "OLLAMA_API_KEY",
395:**Supported APIs:** `openai-completions`, `openai-responses`, `anthropic-messages`, `google-generative-ai`
397:**API key resolution:** The `apiKey` field is checked as environment variable name first, then used as literal value.
399:**API override:** Set `api` at provider level (default for all models) or model level (override per model).
408:      "apiKey": "YOUR_API_KEY",
438:5. First available model with valid API key
508:- Web search and content extraction (Brave Search API)
526:description: Web search via Brave Search API. Use for documentation, facts, or web content.
568:import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
570:export default function (pi: HookAPI) {
587:import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
589:export default function (pi: HookAPI) {
600:> See [Hooks Documentation](docs/hooks.md) for full API reference. pi can help you create new hooks
649:> See [Custom Tools Documentation](docs/custom-tools.md) for the full API reference, TUI component guide, and examples. pi can help you create custom tools.
732:| `--api-key <key>` | API key (overrides environment) |
859:- API key resolution and OAuth
863:> See [SDK Documentation](docs/sdk.md) for the full API reference. See [examples/sdk/](examples/sdk/) for working examples from minimal to full control.


```

### API Keys & OAuth

Set the environment variable for your provider:

| Provider | Environment Variable |
|----------|---------------------|
| Anthropic | `ANTHROPIC_API_KEY` |
| OpenAI | `OPENAI_API_KEY` |
| Google | `GEMINI_API_KEY` |
| Mistral | `MISTRAL_API_KEY` |
| Groq | `GROQ_API_KEY` |
| Cerebras | `CEREBRAS_API_KEY` |
| xAI | `XAI_API_KEY` |
| OpenRouter | `OPENROUTER_API_KEY` |
| ZAI | `ZAI_API_KEY` |

**OAuth Providers:**

Use `/login` to authenticate with subscription-based or free-tier providers:

| Provider | Models | Cost |
|----------|--------|------|
| Anthropic (Claude Pro/Max) | Claude models via your subscription | Subscription |
| GitHub Copilot | GPT-4o, Claude, Gemini via Copilot subscription | Subscription |
| Google Gemini CLI | Gemini 2.0/2.5 models | Free (Google account) |
| Google Antigravity | Gemini 3, Claude, GPT-OSS | Free (Google account) |

```bash
pi
/login  # Select provider, authorize in browser
```

**GitHub Copilot notes:**
- Press Enter for github.com, or enter your GitHub Enterprise Server domain
- If you get "model not supported" error, enable it in VS Code: Copilot Chat → model selector → select model → "Enable"

**Google providers notes:**
- Gemini CLI uses the production Cloud Code Assist endpoint (standard Gemini models)
- Antigravity uses a sandbox endpoint with access to Gemini 3, Claude (sonnet/opus thinking), and GPT-OSS models
- Both are free with any Google account, subject to rate limits

Tokens stored in `~/.pi/agent/oauth.json`. Use `/logout` to clear.

### Quick Start

```bash
export ANTHROPIC_API_KEY=sk-ant-...
pi
```

Then chat:

```

[798 more lines in file. Use offset=160 to continue]


edit

### API Keys & OAuth

Set the environment variable for your provider:

| Provider | Environment Variable |
|----------|---------------------|
| Anthropic | `ANTHROPIC_API_KEY` |
| OpenAI | `OPENAI_API_KEY` |
| Google | `GEMINI_API_KEY` |
| Mistral | `MISTRAL_API_KEY` |
| Groq | `GROQ_API_KEY` |
| Cerebras | `CEREBRAS_API_KEY` |
| xAI | `XAI_API_KEY` |
| OpenRouter | `OPENROUTER_API_KEY` |
| ZAI | `ZAI_API_KEY` |

### API Keys & OAuth

**Option 1: Settings file** (recommended)

Add API keys to `~/.pi/agent/settings.json`:

```json
{
  "apiKeys": {
    "anthropic": "sk-ant-...",
    "openai": "sk-..."
  }
}
```

**Option 2: Environment variables**

| Provider | Environment Variable |
|----------|---------------------|
| Anthropic | `ANTHROPIC_API_KEY` |
| OpenAI | `OPENAI_API_KEY` |
| Google | `GEMINI_API_KEY` |
| Mistral | `MISTRAL_API_KEY` |
| Groq | `GROQ_API_KEY` |
| Cerebras | `CEREBRAS_API_KEY` |
| xAI | `XAI_API_KEY` |
| OpenRouter | `OPENROUTER_API_KEY` |
| ZAI | `ZAI_API_KEY` |

Settings file keys take priority over environment variables.


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/README.md. Changed 415 characters to 644 characters.

     ...
 105 ```
 106
 107 ### API Keys & OAuth
 108
-109 Set the environment variable for your provider:
+109 **Option 1: Settings file** (recommended)
 110
+111 Add API keys to `~/.pi/agent/settings.json`:
+112
+113 ```json
+114 {
+115   "apiKeys": {
+116     "anthropic": "sk-ant-...",
+117     "openai": "sk-..."
+118   }
+119 }
+120 ```
+121
+122 **Option 2: Environment variables**
+123
 111 | Provider | Environment Variable |
 112 |----------|---------------------|
 113 | Anthropic | `ANTHROPIC_API_KEY` |
 114 | OpenAI | `OPENAI_API_KEY` |
 115 | Google | `GEMINI_API_KEY` |
 116 | Mistral | `MISTRAL_API_KEY` |
 117 | Groq | `GROQ_API_KEY` |
 118 | Cerebras | `CEREBRAS_API_KEY` |
 119 | xAI | `XAI_API_KEY` |
 120 | OpenRouter | `OPENROUTER_API_KEY` |
 121 | ZAI | `ZAI_API_KEY` |
 122
+136 Settings file keys take priority over environment variables.
+137
 123 **OAuth Providers:**
 124
 125 Use `/login` to authenticate with subscription-based or free-tier providers:
 126
     ...


> pi-monorepo@0.0.3 check
> biome check --write . && npm run check --workspaces --if-present && tsgo --noEmit

Checked 284 files in 170ms. No fixes applied.

> @mariozechner/pi-agent-core@0.27.2 check
> tsgo --noEmit

> @mariozechner/pi-ai@0.27.2 check
> biome check --write . && tsgo --noEmit

Checked 44 files in 48ms. No fixes applied.

> @mariozechner/pi-coding-agent@0.27.2 check
> tsgo --noEmit && tsgo -p tsconfig.examples.json

> @mariozechner/pi-mom@0.27.2 check
> biome check --write . && tsgo --noEmit

Checked 16 files in 18ms. No fixes applied.

> @mariozechner/pi@0.27.2 check
> biome check --write .

Checked 10 files in 18ms. No fixes applied.

> @mariozechner/pi-proxy@0.27.2 check
> biome check --write .

Checked 3 files in 4ms. No fixes applied.

> @mariozechner/pi-tui@0.27.2 check
> biome check --write . && tsgo --noEmit

Checked 27 files in 25ms. No fixes applied.

> @mariozechner/pi-web-ui@0.27.2 check
> tsgo --noEmit && cd example && tsgo --noEmit

> pi-web-ui-example@1.15.2 check
> tsgo --noEmit


bash

cd /Users/badlogic/workspaces/pi-mono && git add -A && git commit -m "Update README with settings.json API keys" && git push


Running formatting, linting, and type checking...

> pi-monorepo@0.0.3 check
> biome check --write . && npm run check --workspaces --if-present && tsgo --noEmit

Checked 284 files in 155ms. No fixes applied.

> @mariozechner/pi-agent-core@0.27.2 check
> tsgo --noEmit

> @mariozechner/pi-ai@0.27.2 check
> biome check --write . && tsgo --noEmit

Checked 44 files in 38ms. No fixes applied.

> @mariozechner/pi-coding-agent@0.27.2 check
> tsgo --noEmit && tsgo -p tsconfig.examples.json

> @mariozechner/pi-mom@0.27.2 check
> biome check --write . && tsgo --noEmit

Checked 16 files in 17ms. No fixes applied.

> @mariozechner/pi@0.27.2 check
> biome check --write .

Checked 10 files in 17ms. No fixes applied.

> @mariozechner/pi-proxy@0.27.2 check
> biome check --write .

Checked 3 files in 3ms. No fixes applied.

> @mariozechner/pi-tui@0.27.2 check
> biome check --write . && tsgo --noEmit

Checked 27 files in 25ms. No fixes applied.

> @mariozechner/pi-web-ui@0.27.2 check
> tsgo --noEmit && cd example && tsgo --noEmit

> pi-web-ui-example@1.15.2 check
> tsgo --noEmit

✅ All pre-commit checks passed!
[main 144d9d9] Update README with settings.json API keys
 1 file changed, 16 insertions(+), 1 deletion(-)
To https://github.com/badlogic/pi-mono.git
   bb1da1e..144d9d9  main -> main


Done.


well, getApiKeyForModel is called in various places, and that one does not take into account the apikeys in settings. we only set the apikey function on the transport, but lots of places in the coding-agent actually don't use the api key functino from the transport, but instead the global getApiKeyForModel, which only checks cutom model keys, oauth, and the ai package env vars. i guess the issue is that we store that shit globally ...


You're right. The problem is that `getApiKeyForModel` in `model-config.ts` is used directly in many places (model discovery, model switching, etc.) and it doesn't know about settings.

Looking at the options:

1. **Global state in model-config.ts** - Add a `setSettingsApiKeyResolver` that gets called once during init, similar to how `configureOAuthStorage` works. Then `getApiKeyForModel` checks that first.

2. **Pass settingsManager everywhere** - Invasive, touches many files.

3. **Merge apiKeys into the existing `apiKeys` Map in `@mariozechner/pi-ai`** - Call `setApiKey()` from the ai package for each key in settings during startup. Simple but slightly weird since settings can change.

Option 1 seems cleanest. Something like:

```typescript
// In model-config.ts
let settingsApiKeyResolver: ((provider: string) => string | undefined) | null = null;

export function setSettingsApiKeyResolver(resolver: (provider: string) => string | undefined): void {
  settingsApiKeyResolver = resolver;
}

export async function getApiKeyForModel(model: Model<Api>): Promise<string | undefined> {
  // Check settings first
  if (settingsApiKeyResolver) {
    const key = settingsApiKeyResolver(model.provider);
    if (key) return key;
  }
  // ... rest of existing logic
}
```

Then in `createAgentSession`:
```typescript
setSettingsApiKeyResolver((provider) => settingsManager.getApiKey(provider));
```

This mirrors the pattern used for OAuth storage configuration. Want me to implement this?


if we createAgentSession multiple times in the same process, with different getApiKey option, then this fucks up. the global state is not good :/


Yeah, you're right. The global state is fundamentally problematic for multi-session scenarios.

Options:

1. **Accept the limitation** - Document that `apiKeys` in settings.json only works for the CLI, not for SDK users who create multiple sessions with different key resolvers. SDK users should use the `getApiKey` option explicitly.

2. **Thread settingsManager through everything** - Invasive refactor, `getApiKeyForModel` becomes `getApiKeyForModel(model, settingsManager?)`, ripples through model-config, model-resolver, etc.

3. **Make settings lookup part of the ai package** - Add a `setApiKeyResolver` callback there that gets checked before env vars. Still global, same problem.

4. **Lazy approach** - In `createAgentSession`, iterate settings apiKeys and call `setApiKey(provider, key)` from the ai package. This populates the global `apiKeys` Map. Problem: last session wins if they have different settings.

Realistically, for the CLI use case (single session per process), option 1 is probably fine. The current implementation already works for the transport's `getApiKey` callback, which is what matters for actual API calls. The issue is just model discovery/availability checks.

For model discovery, we could document that settings.json apiKeys are used for the active session but model discovery at startup uses env vars/OAuth. Or we accept the slight inconsistency.

What's your preference?


well, the ai package handles oauth, righ? how?


cd /Users/badlogic/workspaces/pi-mono && grep -rn "oauth\|OAuth" packages/ai/src --include="*.ts" | grep -v node_modules | head -30


packages/ai/src/providers/anthropic.ts:118:			const { client, isOAuthToken } = createClient(model, apiKey, options?.interleavedThinking ?? true);
packages/ai/src/providers/anthropic.ts:119:			const params = buildParams(model, context, isOAuthToken, options);
packages/ai/src/providers/anthropic.ts:285:): { client: Anthropic; isOAuthToken: boolean } {
packages/ai/src/providers/anthropic.ts:295:			"anthropic-beta": `oauth-2025-04-20,${betaFeatures.join(",")}`,
packages/ai/src/providers/anthropic.ts:307:		return { client, isOAuthToken: true };
packages/ai/src/providers/anthropic.ts:323:		return { client, isOAuthToken: false };
packages/ai/src/providers/anthropic.ts:330:	isOAuthToken: boolean,
packages/ai/src/providers/anthropic.ts:340:	// For OAuth tokens, we MUST include Claude Code identity
packages/ai/src/providers/anthropic.ts:341:	if (isOAuthToken) {
packages/ai/src/providers/anthropic.ts:361:		// Add cache control to system prompt for non-OAuth tokens
packages/ai/src/providers/google-gemini-cli.ts:152:				throw new Error("Google Cloud Code Assist requires OAuth authentication. Use /login to authenticate.");
packages/ai/src/utils/oauth/anthropic.ts:2: * Anthropic OAuth flow (Claude Pro/Max)
packages/ai/src/utils/oauth/anthropic.ts:6:import { type OAuthCredentials, saveOAuthCredentials } from "./storage.js";
packages/ai/src/utils/oauth/anthropic.ts:10:const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
packages/ai/src/utils/oauth/anthropic.ts:11:const TOKEN_URL = "https://console.anthropic.com/v1/oauth/token";
packages/ai/src/utils/oauth/anthropic.ts:12:const REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback";
packages/ai/src/utils/oauth/anthropic.ts:25: * Login with Anthropic OAuth (device code flow)
packages/ai/src/utils/oauth/anthropic.ts:90:	const credentials: OAuthCredentials = {
packages/ai/src/utils/oauth/anthropic.ts:91:		type: "oauth",
packages/ai/src/utils/oauth/anthropic.ts:97:	saveOAuthCredentials("anthropic", credentials);
packages/ai/src/utils/oauth/anthropic.ts:101: * Refresh Anthropic OAuth token
packages/ai/src/utils/oauth/anthropic.ts:103:export async function refreshAnthropicToken(refreshToken: string): Promise<OAuthCredentials> {
packages/ai/src/utils/oauth/anthropic.ts:126:		type: "oauth",
packages/ai/src/utils/oauth/storage.ts:2: * OAuth credential storage with configurable backend.
packages/ai/src/utils/oauth/storage.ts:4: * Default: ~/.pi/agent/oauth.json
packages/ai/src/utils/oauth/storage.ts:5: * Override with setOAuthStorage() for custom storage locations or backends.
packages/ai/src/utils/oauth/storage.ts:12:export interface OAuthCredentials {
packages/ai/src/utils/oauth/storage.ts:13:	type: "oauth";
packages/ai/src/utils/oauth/storage.ts:22:export interface OAuthStorage {
packages/ai/src/utils/oauth/storage.ts:23:	[provider: string]: OAuthCredentials;


/**
 * OAuth credential storage with configurable backend.
 *
 * Default: ~/.pi/agent/oauth.json
 * Override with setOAuthStorage() for custom storage locations or backends.
 */

import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { homedir } from "os";
import { dirname, join } from "path";

export interface OAuthCredentials {
	type: "oauth";
	refresh: string;
	access: string;
	expires: number;
	enterpriseUrl?: string;
	projectId?: string;
	email?: string;
}

export interface OAuthStorage {
	[provider: string]: OAuthCredentials;
}

export type OAuthProvider = "anthropic" | "github-copilot" | "google-gemini-cli" | "google-antigravity";

/**
 * Storage backend interface.
 * Implement this to use a custom storage location or backend.
 */
export interface OAuthStorageBackend {
	/** Load all OAuth credentials. Return empty object if none exist. */
	load(): OAuthStorage;
	/** Save all OAuth credentials. */
	save(storage: OAuthStorage): void;
}

// ============================================================================
// Default filesystem backend
// ============================================================================

const DEFAULT_PATH = join(homedir(), ".pi", "agent", "oauth.json");

function defaultLoad(): OAuthStorage {
	if (!existsSync(DEFAULT_PATH)) {
		return {};
	}
	try {
		const content = readFileSync(DEFAULT_PATH, "utf-8");
		return JSON.parse(content);
	} catch {
		return {};
	}
}

function defaultSave(storage: OAuthStorage): void {
	const configDir = dirname(DEFAULT_PATH);
	if (!existsSync(configDir)) {
		mkdirSync(configDir, { recursive: true, mode: 0o700 });
	}
	writeFileSync(DEFAULT_PATH, JSON.stringify(storage, null, 2), "utf-8");
	chmodSync(DEFAULT_PATH, 0o600);
}

// ============================================================================
// Configurable backend
// ============================================================================

let currentBackend: OAuthStorageBackend = {
	load: defaultLoad,
	save: defaultSave,
};

/**
 * Configure the OAuth storage backend.
 *
 * @example
 * // Custom file path
 * setOAuthStorage({

[82 more lines in file. Use offset=81 to continue]


/**
 * Configure the OAuth storage backend.
 *
 * @example
 * // Custom file path
 * setOAuthStorage({
 *   load: () => JSON.parse(readFileSync('/custom/path/oauth.json', 'utf-8')),
 *   save: (storage) => writeFileSync('/custom/path/oauth.json', JSON.stringify(storage))
 * });
 *
 * @example
 * // In-memory storage (for testing)
 * let memoryStorage = {};
 * setOAuthStorage({
 *   load: () => memoryStorage,
 *   save: (storage) => { memoryStorage = storage; }
 * });
 */
export function setOAuthStorage(backend: OAuthStorageBackend): void {
	currentBackend = backend;
}

/**
 * Reset to default filesystem storage (~/.pi/agent/oauth.json)
 */
export function resetOAuthStorage(): void {
	currentBackend = { load: defaultLoad, save: defaultSave };
}

/**
 * Get the default OAuth path (for reference, may not be used if custom backend is set)
 */
export function getOAuthPath(): string {
	return DEFAULT_PATH;
}

// ============================================================================
// Public API (uses current backend)
// ============================================================================

/**
 * Load all OAuth credentials
 */
export function loadOAuthStorage(): OAuthStorage {
	return currentBackend.load();
}

/**
 * Load OAuth credentials for a specific provider
 */
export function loadOAuthCredentials(provider: string): OAuthCredentials | null {
	const storage = currentBackend.load();
	return storage[provider] || null;
}

/**
 * Save OAuth credentials for a specific provider
 */
export function saveOAuthCredentials(provider: string, creds: OAuthCredentials): void {
	const storage = currentBackend.load();
	storage[provider] = creds;
	currentBackend.save(storage);
}

/**
 * Remove OAuth credentials for a specific provider
 */
export function removeOAuthCredentials(provider: string): void {
	const storage = currentBackend.load();
	delete storage[provider];
	currentBackend.save(storage);
}

/**
 * Check if OAuth credentials exist for a provider
 */
export function hasOAuthCredentials(provider: string): boolean {
	return loadOAuthCredentials(provider) !== null;
}

[8 more lines in file. Use offset=155 to continue]


I see. OAuth uses `setOAuthStorage()` to configure a global backend. Same global state pattern. The difference is OAuth storage is file-based and doesn't change per-session typically.

For API keys, we could do the same: add a `setApiKeyResolver` in the ai package that `getApiKey`/`resolveApiKey` checks first. It's still global, but:

1. For CLI (single session per process), it works fine
2. For SDK with multiple sessions, the last one wins (same as OAuth currently)

The alternative is accepting that settings.json apiKeys only affect the transport's `getApiKey` callback (actual API calls), not model discovery. Model discovery would still use env vars/OAuth. This is a minor inconsistency but probably acceptable in practice.

What do you think? Add a global `setApiKeyResolver` to the ai package, matching the OAuth pattern?


why does the ai package need that global state? don't we just pass in a validated/refreshed api key to streamSimple/complete/whatever? oauth should be outside of all that machinery. also, where is model discovery handled? why is there model-config.ts and model-resolver.ts?


You're right to question this. Let me check:

cd /Users/badlogic/workspaces/pi-mono && head -50 packages/ai/src/stream.ts


import { ThinkingLevel } from "@google/genai";
import { supportsXhigh } from "./models.js";
import { type AnthropicOptions, streamAnthropic } from "./providers/anthropic.js";
import { type GoogleOptions, streamGoogle } from "./providers/google.js";
import { type GoogleGeminiCliOptions, streamGoogleGeminiCli } from "./providers/google-gemini-cli.js";
import { type OpenAICompletionsOptions, streamOpenAICompletions } from "./providers/openai-completions.js";
import { type OpenAIResponsesOptions, streamOpenAIResponses } from "./providers/openai-responses.js";
import type {
	Api,
	AssistantMessage,
	AssistantMessageEventStream,
	Context,
	KnownProvider,
	Model,
	OptionsForApi,
	ReasoningEffort,
	SimpleStreamOptions,
} from "./types.js";
import { getOAuthApiKey, getOAuthProviderForModelProvider } from "./utils/oauth/index.js";

const apiKeys: Map<string, string> = new Map();

export function setApiKey(provider: KnownProvider, key: string): void;
export function setApiKey(provider: string, key: string): void;
export function setApiKey(provider: any, key: string): void {
	apiKeys.set(provider, key);
}

/**
 * Get API key from environment variables (sync).
 * Does NOT check OAuth credentials - use getApiKeyAsync for that.
 */
export function getApiKey(provider: KnownProvider): string | undefined;
export function getApiKey(provider: string): string | undefined;
export function getApiKey(provider: any): string | undefined {
	// Check explicit keys first
	const key = apiKeys.get(provider);
	if (key) return key;

	// Fall back to environment variables
	if (provider === "github-copilot") {
		return process.env.COPILOT_GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
	}

	const envMap: Record<string, string> = {
		openai: "OPENAI_API_KEY",
		anthropic: "ANTHROPIC_API_KEY",
		google: "GEMINI_API_KEY",
		groq: "GROQ_API_KEY",
		cerebras: "CEREBRAS_API_KEY",


cd /Users/badlogic/workspaces/pi-mono && grep -n "getApiKey\|resolveApiKey" packages/ai/src/stream.ts


31: * Does NOT check OAuth credentials - use getApiKeyAsync for that.
33:export function getApiKey(provider: KnownProvider): string | undefined;
34:export function getApiKey(provider: string): string | undefined;
35:export function getApiKey(provider: any): string | undefined {
70:export async function resolveApiKey(provider: KnownProvider): Promise<string | undefined>;
71:export async function resolveApiKey(provider: string): Promise<string | undefined>;
72:export async function resolveApiKey(provider: any): Promise<string | undefined> {
84:	// Fall back to sync getApiKey for env vars
85:	return getApiKey(provider);
93:	const apiKey = options?.apiKey || getApiKey(model.provider);
142:	const apiKey = options?.apiKey || getApiKey(model.provider);


cd /Users/badlogic/workspaces/pi-mono && head -30 packages/coding-agent/src/core/model-config.ts


import {
	type Api,
	getApiKey,
	getGitHubCopilotBaseUrl,
	getModels,
	getProviders,
	type KnownProvider,
	loadOAuthCredentials,
	type Model,
	normalizeDomain,
	refreshGitHubCopilotToken,
	removeOAuthCredentials,
	saveOAuthCredentials,
} from "@mariozechner/pi-ai";
import { type Static, Type } from "@sinclair/typebox";
import AjvModule from "ajv";
import { existsSync, readFileSync } from "fs";
import { join } from "path";
import { getAgentDir } from "../config.js";
import { getOAuthToken, type OAuthProvider, refreshToken } from "./oauth/index.js";

// Handle both default and named exports
const Ajv = (AjvModule as any).default || AjvModule;

// Schema for OpenAI compatibility settings
const OpenAICompatSchema = Type.Object({
	supportsStore: Type.Optional(Type.Boolean()),
	supportsDeveloperRole: Type.Optional(Type.Boolean()),
	supportsReasoningEffort: Type.Optional(Type.Boolean()),
	maxTokensField: Type.Optional(Type.Union([Type.Literal("max_completion_tokens"), Type.Literal("max_tokens")])),


cd /Users/badlogic/workspaces/pi-mono && head -30 packages/coding-agent/src/core/model-resolver.ts


/**
 * Model resolution, scoping, and initial selection
 */

import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
import type { Api, KnownProvider, Model } from "@mariozechner/pi-ai";
import chalk from "chalk";
import { isValidThinkingLevel } from "../cli/args.js";
import { findModel, getApiKeyForModel, getAvailableModels } from "./model-config.js";
import type { SettingsManager } from "./settings-manager.js";

/** Default model IDs for each known provider */
export const defaultModelPerProvider: Record<KnownProvider, string> = {
	anthropic: "claude-sonnet-4-5",
	openai: "gpt-5.1-codex",
	google: "gemini-2.5-pro",
	"google-gemini-cli": "gemini-2.5-pro",
	"google-antigravity": "gemini-3-pro-high",
	"github-copilot": "gpt-4o",
	openrouter: "openai/gpt-5.1-codex",
	xai: "grok-4-fast-non-reasoning",
	groq: "openai/gpt-oss-120b",
	cerebras: "zai-glm-4.6",
	zai: "glm-4.6",
	mistral: "devstral-medium-latest",
};

export interface ScopedModel {
	model: Model<Api>;
	thinkingLevel: ThinkingLevel;


So `model-config.ts` handles:
- Loading/merging models from built-in + models.json
- `getApiKeyForModel` - resolving API keys (custom providers, OAuth, env vars)
- Model availability checking

And `model-resolver.ts` handles:
- CLI argument parsing for models (`--model provider/id:thinking`)
- Model scoping/selection logic
- Default model per provider

The ai package's `getApiKey`/`resolveApiKey` is used by `stream()`/`streamSimple()` when no apiKey option is passed. But in coding-agent, we always pass the apiKey via the transport's `getApiKey` callback.

So the ai package's global `getApiKey` is really only used:
1. By `model-config.ts`'s `getApiKeyForModel` as a fallback for built-in providers
2. Directly by `stream()`/`streamSimple()` when called without explicit apiKey (not our case)

The real issue is `getApiKeyForModel` in `model-config.ts` is called from many places for model discovery/availability. We could refactor it to accept an optional resolver, or we accept that settings.json keys only affect the transport callback.

Want me to refactor `getApiKeyForModel` to accept an optional `settingsApiKeyResolver` parameter?


agent-session.ts also calls getApiKeyForModel


cd /Users/badlogic/workspaces/pi-mono && grep -n "getApiKeyForModel" packages/coding-agent/src/core/agent-session.ts


26:import { getApiKeyForModel, getAvailableModels } from "./model-config.js";
431:		const apiKey = await getApiKeyForModel(this.model);
558:		const apiKey = await getApiKeyForModel(model);
596:		const apiKey = await getApiKeyForModel(next.model);
626:		const apiKey = await getApiKeyForModel(nextModel);
743:			const apiKey = await getApiKeyForModel(this.model);
843:			const apiKey = await getApiKeyForModel(this.model);


	 * @throws Error if no API key available for the model
	 */
	async setModel(model: Model<any>): Promise<void> {
		const apiKey = await getApiKeyForModel(model);
		if (!apiKey) {
			throw new Error(`No API key for ${model.provider}/${model.id}`);
		}

		this.agent.setModel(model);
		this.sessionManager.saveModelChange(model.provider, model.id);
		this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);

		// Re-clamp thinking level for new model's capabilities
		this.setThinkingLevel(this.thinkingLevel);
	}

	/**
	 * Cycle to next model.
	 * Uses scoped models (from --models flag) if available, otherwise all available models.
	 * @returns The new model info, or null if only one model available

[896 more lines in file. Use offset=575 to continue]


I see. `AgentSession` has access to `settingsManager` (it's passed in during construction). So we could:

1. Add a method on `AgentSession` that wraps `getApiKeyForModel` and checks settings first
2. Or refactor `getApiKeyForModel` to accept an optional resolver

Actually, `AgentSession` already has `settingsManager`. The cleanest fix would be to add a private helper in `AgentSession` that checks settings first:

```typescript
private async resolveApiKey(model: Model<any>): Promise<string | undefined> {
    const settingsKey = this.settingsManager.getApiKey(model.provider);
    if (settingsKey) return settingsKey;
    return getApiKeyForModel(model);
}
```

Then replace all `getApiKeyForModel` calls in `agent-session.ts` with `this.resolveApiKey`.

For `model-config.ts` and `sdk.ts`, they already have access to settingsManager in the relevant contexts.

This keeps the change localized and doesn't require global state. Want me to do this?


sounds good, do it


	// Bash execution state
	private _bashAbortController: AbortController | null = null;
	private _pendingBashMessages: BashExecutionMessage[] = [];

	// Hook system
	private _hookRunner: HookRunner | null = null;
	private _turnIndex = 0;

	// Custom tools for session lifecycle
	private _customTools: LoadedCustomTool[] = [];

	private _skillsSettings: Required<SkillsSettings> | undefined;

	constructor(config: AgentSessionConfig) {
		this.agent = config.agent;
		this.sessionManager = config.sessionManager;
		this.settingsManager = config.settingsManager;
		this._scopedModels = config.scopedModels ?? [];
		this._fileCommands = config.fileCommands ?? [];
		this._hookRunner = config.hookRunner ?? null;
		this._customTools = config.customTools ?? [];
		this._skillsSettings = config.skillsSettings;
	}

	// =========================================================================
	// Event Subscription
	// =========================================================================

	/** Emit an event to all listeners */
	private _emit(event: AgentSessionEvent): void {
		for (const l of this._eventListeners) {
			l(event);
		}
	}

	// Track last assistant message for auto-compaction check
	private _lastAssistantMessage: AssistantMessage | null = null;

	/** Internal handler for agent events - shared by subscribe and reconnect */

[1291 more lines in file. Use offset=180 to continue]


		this._hookRunner = config.hookRunner ?? null;
		this._customTools = config.customTools ?? [];
		this._skillsSettings = config.skillsSettings;
	}

	// =========================================================================
	// Event Subscription
	// =========================================================================

	/** Emit an event to all listeners */
	private _emit(event: AgentSessionEvent): void {
		for (const l of this._eventListeners) {
			l(event);
		}
	}

	// Track last assistant message for auto-compaction check
	private _lastAssistantMessage: AssistantMessage | null = null;

	/** Internal handler for agent events - shared by subscribe and reconnect */
	private _handleAgentEvent = async (event: AgentEvent): Promise<void> => {
		// When a user message starts, check if it's from the queue and remove it BEFORE emitting
		// This ensures the UI sees the updated queue state
		if (event.type === "message_start" && event.message.role === "user" && this._queuedMessages.length > 0) {
			// Extract text content from the message
			const messageText = this._getUserMessageText(event.message);
			if (messageText && this._queuedMessages.includes(messageText)) {
				// Remove the first occurrence of this message from the queue
				const index = this._queuedMessages.indexOf(messageText);
				if (index !== -1) {

[1281 more lines in file. Use offset=190 to continue]


actually, i think AgentSessionConfig should take a resolveApiKey(provider: string)?


Good idea. That's cleaner - the session gets its resolver injected, and `createAgentSession` can construct it with settings awareness.


export type AgentSessionEventListener = (event: AgentSessionEvent) => void;

// ============================================================================
// Types
// ============================================================================

export interface AgentSessionConfig {
	agent: Agent;
	sessionManager: SessionManager;
	settingsManager: SettingsManager;
	/** Models to cycle through with Ctrl+P (from --models flag) */
	scopedModels?: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>;
	/** File-based slash commands for expansion */
	fileCommands?: FileSlashCommand[];
	/** Hook runner (created in main.ts with wrapped tools) */
	hookRunner?: HookRunner | null;
	/** Custom tools for session lifecycle events */
	customTools?: LoadedCustomTool[];
	skillsSettings?: Required<SkillsSettings>;
}

/** Options for AgentSession.prompt() */
export interface PromptOptions {
	/** Whether to expand file-based slash commands (default: true) */
	expandSlashCommands?: boolean;
	/** Image/file attachments */
	attachments?: Attachment[];
}

/** Result from cycleModel() */

[1401 more lines in file. Use offset=70 to continue]


edit

export interface AgentSessionConfig {
	agent: Agent;
	sessionManager: SessionManager;
	settingsManager: SettingsManager;
	/** Models to cycle through with Ctrl+P (from --models flag) */
	scopedModels?: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>;
	/** File-based slash commands for expansion */
	fileCommands?: FileSlashCommand[];
	/** Hook runner (created in main.ts with wrapped tools) */
	hookRunner?: HookRunner | null;
	/** Custom tools for session lifecycle events */
	customTools?: LoadedCustomTool[];
	skillsSettings?: Required<SkillsSettings>;
}

export interface AgentSessionConfig {
	agent: Agent;
	sessionManager: SessionManager;
	settingsManager: SettingsManager;
	/** Models to cycle through with Ctrl+P (from --models flag) */
	scopedModels?: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>;
	/** File-based slash commands for expansion */
	fileCommands?: FileSlashCommand[];
	/** Hook runner (created in main.ts with wrapped tools) */
	hookRunner?: HookRunner | null;
	/** Custom tools for session lifecycle events */
	customTools?: LoadedCustomTool[];
	skillsSettings?: Required<SkillsSettings>;
	/** Resolve API key for a model. Default: getApiKeyForModel */
	resolveApiKey?: (model: Model<any>) => Promise<string | undefined>;
}


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts. Changed 569 characters to 702 characters.

      ...
   55 	hookRunner?: HookRunner | null;
   56 	/** Custom tools for session lifecycle events */
   57 	customTools?: LoadedCustomTool[];
   58 	skillsSettings?: Required<SkillsSettings>;
+  59 	/** Resolve API key for a model. Default: getApiKeyForModel */
+  60 	resolveApiKey?: (model: Model<any>) => Promise<string | undefined>;
   59 }
   60
   61 /** Options for AgentSession.prompt() */
   62 export interface PromptOptions {
      ...


Now add the field and use it in the constructor:


	// Message queue state
	private _queuedMessages: string[] = [];

	// Compaction state
	private _compactionAbortController: AbortController | null = null;
	private _autoCompactionAbortController: AbortController | null = null;

	// Retry state
	private _retryAbortController: AbortController | null = null;
	private _retryAttempt = 0;
	private _retryPromise: Promise<void> | null = null;
	private _retryResolve: (() => void) | null = null;

	// Bash execution state
	private _bashAbortController: AbortController | null = null;
	private _pendingBashMessages: BashExecutionMessage[] = [];

	// Hook system
	private _hookRunner: HookRunner | null = null;
	private _turnIndex = 0;

	// Custom tools for session lifecycle
	private _customTools: LoadedCustomTool[] = [];

	private _skillsSettings: Required<SkillsSettings> | undefined;

	constructor(config: AgentSessionConfig) {
		this.agent = config.agent;
		this.sessionManager = config.sessionManager;
		this.settingsManager = config.settingsManager;
		this._scopedModels = config.scopedModels ?? [];
		this._fileCommands = config.fileCommands ?? [];
		this._hookRunner = config.hookRunner ?? null;
		this._customTools = config.customTools ?? [];
		this._skillsSettings = config.skillsSettings;
	}

	// =========================================================================
	// Event Subscription
	// =========================================================================

[1303 more lines in file. Use offset=170 to continue]


	// Custom tools for session lifecycle
	private _customTools: LoadedCustomTool[] = [];

	private _skillsSettings: Required<SkillsSettings> | undefined;

	constructor(config: AgentSessionConfig) {
		this.agent = config.agent;
		this.sessionManager = config.sessionManager;
		this.settingsManager = config.settingsManager;
		this._scopedModels = config.scopedModels ?? [];
		this._fileCommands = config.fileCommands ?? [];
		this._hookRunner = config.hookRunner ?? null;
		this._customTools = config.customTools ?? [];
		this._skillsSettings = config.skillsSettings;
	}

	// Custom tools for session lifecycle
	private _customTools: LoadedCustomTool[] = [];

	private _skillsSettings: Required<SkillsSettings> | undefined;

	// API key resolver
	private _resolveApiKey: (model: Model<any>) => Promise<string | undefined>;

	constructor(config: AgentSessionConfig) {
		this.agent = config.agent;
		this.sessionManager = config.sessionManager;
		this.settingsManager = config.settingsManager;
		this._scopedModels = config.scopedModels ?? [];
		this._fileCommands = config.fileCommands ?? [];
		this._hookRunner = config.hookRunner ?? null;
		this._customTools = config.customTools ?? [];
		this._skillsSettings = config.skillsSettings;
		this._resolveApiKey = config.resolveApiKey ?? getApiKeyForModel;
	}


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts. Changed 567 characters to 733 characters.

      ...
  152 	private _customTools: LoadedCustomTool[] = [];
  153
  154 	private _skillsSettings: Required<SkillsSettings> | undefined;
  155
+ 156 	// API key resolver
+ 157 	private _resolveApiKey: (model: Model<any>) => Promise<string | undefined>;
+ 158
  156 	constructor(config: AgentSessionConfig) {
  157 		this.agent = config.agent;
  158 		this.sessionManager = config.sessionManager;
  159 		this.settingsManager = config.settingsManager;
  160 		this._scopedModels = config.scopedModels ?? [];
  161 		this._fileCommands = config.fileCommands ?? [];
  162 		this._hookRunner = config.hookRunner ?? null;
  163 		this._customTools = config.customTools ?? [];
  164 		this._skillsSettings = config.skillsSettings;
+ 168 		this._resolveApiKey = config.resolveApiKey ?? getApiKeyForModel;
  165 	}
  166
  167 	// =========================================================================
  168 	// Event Subscription
      ...


Now replace all calls to `getApiKeyForModel` with `this._resolveApiKey`:

bash

cd /Users/badlogic/workspaces/pi-mono && grep -n "await getApiKeyForModel" packages/coding-agent/src/core/agent-session.ts


437:		const apiKey = await getApiKeyForModel(this.model);
564:		const apiKey = await getApiKeyForModel(model);
602:		const apiKey = await getApiKeyForModel(next.model);
632:		const apiKey = await getApiKeyForModel(nextModel);
749:			const apiKey = await getApiKeyForModel(this.model);
849:			const apiKey = await getApiKeyForModel(this.model);


cd /Users/badlogic/workspaces/pi-mono && sed -i '' 's/await getApiKeyForModel(/await this._resolveApiKey(/g' packages/coding-agent/src/core/agent-session.ts


(no output)


26:import { getApiKeyForModel, getAvailableModels } from "./model-config.js";
59:	/** Resolve API key for a model. Default: getApiKeyForModel */
168:		this._resolveApiKey = config.resolveApiKey ?? getApiKeyForModel;


Good, only the import and default remain. Now update `sdk.ts` to pass the resolver:


			},
		}),
	});
	time("createAgent");

	// Restore messages if session has existing data
	if (hasExistingSession) {
		agent.replaceMessages(existingSession.messages);
	}

	const session = new AgentSession({
		agent,
		sessionManager,
		settingsManager,
		scopedModels: options.scopedModels,
		fileCommands: slashCommands,
		hookRunner,
		customTools: customToolsResult.tools,
		skillsSettings: settingsManager.getSkillsSettings(),
	});
	time("createAgentSession");

	return {
		session,
		customToolsResult,
		modelFallbackMessage,
	};
}


edit

	const session = new AgentSession({
		agent,
		sessionManager,
		settingsManager,
		scopedModels: options.scopedModels,
		fileCommands: slashCommands,
		hookRunner,
		customTools: customToolsResult.tools,
		skillsSettings: settingsManager.getSkillsSettings(),
	});
	time("createAgentSession");

	const session = new AgentSession({
		agent,
		sessionManager,
		settingsManager,
		scopedModels: options.scopedModels,
		fileCommands: slashCommands,
		hookRunner,
		customTools: customToolsResult.tools,
		skillsSettings: settingsManager.getSkillsSettings(),
		resolveApiKey: getApiKey,
	});
	time("createAgentSession");


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/sdk.ts. Changed 293 characters to 321 characters.

     ...
 680 		fileCommands: slashCommands,
 681 		hookRunner,
 682 		customTools: customToolsResult.tools,
 683 		skillsSettings: settingsManager.getSkillsSettings(),
+684 		resolveApiKey: getApiKey,
 684 	});
 685 	time("createAgentSession");
 686
 687 	return {
     ...


Now let's run the check:

bash

cd /Users/badlogic/workspaces/pi-mono && npm run check 2>&1


> pi-monorepo@0.0.3 check
> biome check --write . && npm run check --workspaces --if-present && tsgo --noEmit

Checked 284 files in 158ms. No fixes applied.

> @mariozechner/pi-agent-core@0.27.2 check
> tsgo --noEmit

> @mariozechner/pi-ai@0.27.2 check
> biome check --write . && tsgo --noEmit

Checked 44 files in 39ms. No fixes applied.

> @mariozechner/pi-coding-agent@0.27.2 check
> tsgo --noEmit && tsgo -p tsconfig.examples.json

> @mariozechner/pi-mom@0.27.2 check
> biome check --write . && tsgo --noEmit

Checked 16 files in 21ms. No fixes applied.

> @mariozechner/pi@0.27.2 check
> biome check --write .

Checked 10 files in 17ms. No fixes applied.

> @mariozechner/pi-proxy@0.27.2 check
> biome check --write .

Checked 3 files in 3ms. No fixes applied.

> @mariozechner/pi-tui@0.27.2 check
> biome check --write . && tsgo --noEmit

Checked 27 files in 25ms. No fixes applied.

> @mariozechner/pi-web-ui@0.27.2 check
> tsgo --noEmit && cd example && tsgo --noEmit

> pi-web-ui-example@1.15.2 check
> tsgo --noEmit


All checks pass. Let me verify the changes look correct:


 packages/coding-agent/src/core/agent-session.ts | 18 ++++++++++++------
 packages/coding-agent/src/core/sdk.ts           |  1 +
 2 files changed, 13 insertions(+), 6 deletions(-)


cd /Users/badlogic/workspaces/pi-mono && git diff


diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts
index 883b27a..c066688 100644
--- a/packages/coding-agent/src/core/agent-session.ts
+++ b/packages/coding-agent/src/core/agent-session.ts
@@ -56,6 +56,8 @@ export interface AgentSessionConfig {
 	/** Custom tools for session lifecycle events */
 	customTools?: LoadedCustomTool[];
 	skillsSettings?: Required<SkillsSettings>;
+	/** Resolve API key for a model. Default: getApiKeyForModel */
+	resolveApiKey?: (model: Model<any>) => Promise<string | undefined>;
 }

 /** Options for AgentSession.prompt() */
@@ -151,6 +153,9 @@ export class AgentSession {

 	private _skillsSettings: Required<SkillsSettings> | undefined;

+	// API key resolver
+	private _resolveApiKey: (model: Model<any>) => Promise<string | undefined>;
+
 	constructor(config: AgentSessionConfig) {
 		this.agent = config.agent;
 		this.sessionManager = config.sessionManager;
@@ -160,6 +165,7 @@ export class AgentSession {
 		this._hookRunner = config.hookRunner ?? null;
 		this._customTools = config.customTools ?? [];
 		this._skillsSettings = config.skillsSettings;
+		this._resolveApiKey = config.resolveApiKey ?? getApiKeyForModel;
 	}

 	// =========================================================================
@@ -428,7 +434,7 @@ export class AgentSession {
 		}

 		// Validate API key
-		const apiKey = await getApiKeyForModel(this.model);
+		const apiKey = await this._resolveApiKey(this.model);
 		if (!apiKey) {
 			throw new Error(
 				`No API key found for ${this.model.provider}.\n\n` +
@@ -555,7 +561,7 @@ export class AgentSession {
 	 * @throws Error if no API key available for the model
 	 */
 	async setModel(model: Model<any>): Promise<void> {
-		const apiKey = await getApiKeyForModel(model);
+		const apiKey = await this._resolveApiKey(model);
 		if (!apiKey) {
 			throw new Error(`No API key for ${model.provider}/${model.id}`);
 		}
@@ -593,7 +599,7 @@ export class AgentSession {
 		const next = this._scopedModels[nextIndex];

 		// Validate API key
-		const apiKey = await getApiKeyForModel(next.model);
+		const apiKey = await this._resolveApiKey(next.model);
 		if (!apiKey) {
 			throw new Error(`No API key for ${next.model.provider}/${next.model.id}`);
 		}
@@ -623,7 +629,7 @@ export class AgentSession {
 		const nextIndex = (currentIndex + 1) % availableModels.length;
 		const nextModel = availableModels[nextIndex];

-		const apiKey = await getApiKeyForModel(nextModel);
+		const apiKey = await this._resolveApiKey(nextModel);
 		if (!apiKey) {
 			throw new Error(`No API key for ${nextModel.provider}/${nextModel.id}`);
 		}
@@ -740,7 +746,7 @@ export class AgentSession {
 				throw new Error("No model selected");
 			}

-			const apiKey = await getApiKeyForModel(this.model);
+			const apiKey = await this._resolveApiKey(this.model);
 			if (!apiKey) {
 				throw new Error(`No API key for ${this.model.provider}`);
 			}
@@ -840,7 +846,7 @@ export class AgentSession {
 				return;
 			}

-			const apiKey = await getApiKeyForModel(this.model);
+			const apiKey = await this._resolveApiKey(this.model);
 			if (!apiKey) {
 				this._emit({ type: "auto_compaction_end", result: null, aborted: false, willRetry: false });
 				return;
diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts
index e2b3a81..2c13528 100644
--- a/packages/coding-agent/src/core/sdk.ts
+++ b/packages/coding-agent/src/core/sdk.ts
@@ -681,6 +681,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
 		hookRunner,
 		customTools: customToolsResult.tools,
 		skillsSettings: settingsManager.getSkillsSettings(),
+		resolveApiKey: getApiKey,
 	});
 	time("createAgentSession");


Looks good. Now `AgentSession` uses the injected `resolveApiKey` which in `createAgentSession` is set to the `getApiKey` function that checks settings first.

cd /Users/badlogic/workspaces/pi-mono && git add -A && git commit -m "Use injected resolveApiKey in AgentSession for settings.json support" && git push


Running formatting, linting, and type checking...

> pi-monorepo@0.0.3 check
> biome check --write . && npm run check --workspaces --if-present && tsgo --noEmit

Checked 284 files in 154ms. No fixes applied.

> @mariozechner/pi-agent-core@0.27.2 check
> tsgo --noEmit

> @mariozechner/pi-ai@0.27.2 check
> biome check --write . && tsgo --noEmit

Checked 44 files in 47ms. No fixes applied.

> @mariozechner/pi-coding-agent@0.27.2 check
> tsgo --noEmit && tsgo -p tsconfig.examples.json

> @mariozechner/pi-mom@0.27.2 check
> biome check --write . && tsgo --noEmit

Checked 16 files in 17ms. No fixes applied.

> @mariozechner/pi@0.27.2 check
> biome check --write .

Checked 10 files in 17ms. No fixes applied.

> @mariozechner/pi-proxy@0.27.2 check
> biome check --write .

Checked 3 files in 3ms. No fixes applied.

> @mariozechner/pi-tui@0.27.2 check
> biome check --write . && tsgo --noEmit

Checked 27 files in 25ms. No fixes applied.

> @mariozechner/pi-web-ui@0.27.2 check
> tsgo --noEmit && cd example && tsgo --noEmit

> pi-web-ui-example@1.15.2 check
> tsgo --noEmit

✅ All pre-commit checks passed!
[main 541758f] Use injected resolveApiKey in AgentSession for settings.json support
 2 files changed, 13 insertions(+), 6 deletions(-)
To https://github.com/badlogic/pi-mono.git
   144d9d9..541758f  main -> main


Done. Now `AgentSession` gets its API key resolver injected, and `createAgentSession` passes one that checks settings.json first. This avoids the global state problem.


```json
{
  "apiKeys": {
    "anthropic": "sk-ant-...",
    "openai": "sk-..."
  }
}

should list all the key names here in README.md


```

### API Keys & OAuth

**Option 1: Settings file** (recommended)

Add API keys to `~/.pi/agent/settings.json`:

```json
{
  "apiKeys": {
    "anthropic": "sk-ant-...",
    "openai": "sk-..."
  }
}
```

**Option 2: Environment variables**

| Provider | Environment Variable |
|----------|---------------------|
| Anthropic | `ANTHROPIC_API_KEY` |
| OpenAI | `OPENAI_API_KEY` |
| Google | `GEMINI_API_KEY` |
| Mistral | `MISTRAL_API_KEY` |
| Groq | `GROQ_API_KEY` |
| Cerebras | `CEREBRAS_API_KEY` |
| xAI | `XAI_API_KEY` |
| OpenRouter | `OPENROUTER_API_KEY` |
| ZAI | `ZAI_API_KEY` |

Settings file keys take priority over environment variables.

**OAuth Providers:**

Use `/login` to authenticate with subscription-based or free-tier providers:

| Provider | Models | Cost |
|----------|--------|------|
| Anthropic (Claude Pro/Max) | Claude models via your subscription | Subscription |

[828 more lines in file. Use offset=145 to continue]


edit

**Option 1: Settings file** (recommended)

Add API keys to `~/.pi/agent/settings.json`:

```json
{
  "apiKeys": {
    "anthropic": "sk-ant-...",
    "openai": "sk-..."
  }
}
```

**Option 2: Environment variables**

| Provider | Environment Variable |
|----------|---------------------|
| Anthropic | `ANTHROPIC_API_KEY` |
| OpenAI | `OPENAI_API_KEY` |
| Google | `GEMINI_API_KEY` |
| Mistral | `MISTRAL_API_KEY` |
| Groq | `GROQ_API_KEY` |
| Cerebras | `CEREBRAS_API_KEY` |
| xAI | `XAI_API_KEY` |
| OpenRouter | `OPENROUTER_API_KEY` |
| ZAI | `ZAI_API_KEY` |

Settings file keys take priority over environment variables.

**Option 1: Settings file** (recommended)

Add API keys to `~/.pi/agent/settings.json`:

```json
{
  "apiKeys": {
    "anthropic": "sk-ant-...",
    "openai": "sk-...",
    "google": "...",
    "mistral": "...",
    "groq": "...",
    "cerebras": "...",
    "xai": "...",
    "openrouter": "...",
    "zai": "..."
  }
}
```

**Option 2: Environment variables**

| Provider | Settings Key | Environment Variable |
|----------|--------------|---------------------|
| Anthropic | `anthropic` | `ANTHROPIC_API_KEY` |
| OpenAI | `openai` | `OPENAI_API_KEY` |
| Google | `google` | `GEMINI_API_KEY` |
| Mistral | `mistral` | `MISTRAL_API_KEY` |
| Groq | `groq` | `GROQ_API_KEY` |
| Cerebras | `cerebras` | `CEREBRAS_API_KEY` |
| xAI | `xai` | `XAI_API_KEY` |
| OpenRouter | `openrouter` | `OPENROUTER_API_KEY` |
| ZAI | `zai` | `ZAI_API_KEY` |

Settings file keys take priority over environment variables.


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/README.md. Changed 622 characters to 899 characters.

     ...
 113 ```json
 114 {
 115   "apiKeys": {
 116     "anthropic": "sk-ant-...",
-117     "openai": "sk-..."
+117     "openai": "sk-...",
+118     "google": "...",
+119     "mistral": "...",
+120     "groq": "...",
+121     "cerebras": "...",
+122     "xai": "...",
+123     "openrouter": "...",
+124     "zai": "..."
 118   }
 119 }
 120 ```
 121
 122 **Option 2: Environment variables**
 123
-124 | Provider | Environment Variable |
-125 |----------|---------------------|
-126 | Anthropic | `ANTHROPIC_API_KEY` |
-127 | OpenAI | `OPENAI_API_KEY` |
-128 | Google | `GEMINI_API_KEY` |
-129 | Mistral | `MISTRAL_API_KEY` |
-130 | Groq | `GROQ_API_KEY` |
-131 | Cerebras | `CEREBRAS_API_KEY` |
-132 | xAI | `XAI_API_KEY` |
-133 | OpenRouter | `OPENROUTER_API_KEY` |
-134 | ZAI | `ZAI_API_KEY` |
+131 | Provider | Settings Key | Environment Variable |
+132 |----------|--------------|---------------------|
+133 | Anthropic | `anthropic` | `ANTHROPIC_API_KEY` |
+134 | OpenAI | `openai` | `OPENAI_API_KEY` |
+135 | Google | `google` | `GEMINI_API_KEY` |
+136 | Mistral | `mistral` | `MISTRAL_API_KEY` |
+137 | Groq | `groq` | `GROQ_API_KEY` |
+138 | Cerebras | `cerebras` | `CEREBRAS_API_KEY` |
+139 | xAI | `xai` | `XAI_API_KEY` |
+140 | OpenRouter | `openrouter` | `OPENROUTER_API_KEY` |
+141 | ZAI | `zai` | `ZAI_API_KEY` |
 135
 136 Settings file keys take priority over environment variables.
 137
 138 **OAuth Providers:**
     ...


> pi-monorepo@0.0.3 check
> biome check --write . && npm run check --workspaces --if-present && tsgo --noEmit

Checked 284 files in 167ms. Fixed 1 file.

> @mariozechner/pi-agent-core@0.27.2 check
> tsgo --noEmit

> @mariozechner/pi-ai@0.27.2 check
> biome check --write . && tsgo --noEmit

Checked 44 files in 44ms. No fixes applied.

> @mariozechner/pi-coding-agent@0.27.2 check
> tsgo --noEmit && tsgo -p tsconfig.examples.json

> @mariozechner/pi-mom@0.27.2 check
> biome check --write . && tsgo --noEmit

Checked 16 files in 17ms. No fixes applied.

> @mariozechner/pi@0.27.2 check
> biome check --write .

Checked 10 files in 17ms. No fixes applied.

> @mariozechner/pi-proxy@0.27.2 check
> biome check --write .

Checked 3 files in 4ms. No fixes applied.

> @mariozechner/pi-tui@0.27.2 check
> biome check --write . && tsgo --noEmit

Checked 27 files in 28ms. No fixes applied.

> @mariozechner/pi-web-ui@0.27.2 check
> tsgo --noEmit && cd example && tsgo --noEmit

> pi-web-ui-example@1.15.2 check
> tsgo --noEmit


bash

cd /Users/badlogic/workspaces/pi-mono && git add -A && git commit -m "List all provider keys in README settings example" && git push