diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b5ff19f6..fe2a7b90 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,7 +2,9 @@ ## [Unreleased] -(nothing yet) +### Fixed + +- **Model selector and --list-models with settings.json API keys**: Models with API keys configured in settings.json (but not in environment variables) now properly appear in the /model selector and `--list-models` output. ([#295](https://github.com/badlogic/pi-mono/issues/295)) ## [0.27.8] - 2025-12-24 diff --git a/packages/coding-agent/docs/hooks-v2.md b/packages/coding-agent/docs/hooks-v2.md index 5fed9c6f..287584b6 100644 --- a/packages/coding-agent/docs/hooks-v2.md +++ b/packages/coding-agent/docs/hooks-v2.md @@ -1,314 +1,385 @@ -# Hooks v2: Commands + Context Control +# Hooks v2: Context Control + Commands -Extends hooks with slash commands and context manipulation primitives. +Issue: #289 -## Goals +## Motivation -1. Hooks can register slash commands (`/pop`, `/pr`, `/test`) -2. Hooks can save custom session entries -3. Hooks can transform context before it goes to LLM -4. All handlers get unified baseline access to state +Enable features like session stacking (`/pop`) as hooks, not core code. Core provides primitives, hooks implement features. -Benchmark: `/pop` (session stacking) implementable entirely as a hook. +## Primitives -## API Extensions +| Primitive | Purpose | +|-----------|---------| +| `ctx.saveEntry({type, ...})` | Persist custom entry to session | +| `pi.on("context", handler)` | Transform messages before LLM | +| `ctx.rebuildContext()` | Trigger context rebuild | +| `pi.command(name, opts)` | Register slash command | -### Commands - -```typescript -pi.command("pop", { - description: "Pop to previous turn", - handler: async (ctx) => { - // ctx has full access (see Unified Context below) - const selected = await ctx.ui.select("Pop to:", options); - // ... - return { status: "Done" }; // show status - return "prompt text"; // send to agent - return; // do nothing - } -}); -``` - -### Custom Entries - -```typescript -// Save arbitrary entry to session -await ctx.saveEntry({ - type: "stack_pop", // custom type, ignored by core - backToIndex: 5, - summary: "...", - timestamp: Date.now() -}); -``` - -### Context Transform - -```typescript -// Fires when building context for LLM -pi.on("context", (event, ctx) => { - // event.entries: all session entries (including custom types) - // event.messages: core-computed messages (after compaction) - - // Return modified messages, or undefined to keep default - return { messages: transformed }; -}); -``` - -Multiple `context` handlers chain: each receives previous handler's output. - -### Rebuild Trigger - -```typescript -// Force context rebuild (after saving entries) -await ctx.rebuildContext(); -``` - -## Unified Context - -All handlers receive: +## Extended HookEventContext ```typescript interface HookEventContext { // Existing - exec(cmd: string, args: string[], opts?): Promise; - ui: { select, confirm, input, notify }; - hasUI: boolean; - cwd: string; - sessionFile: string | null; - - // New: State (read-only) + exec, ui, hasUI, cwd, sessionFile + + // State (read-only) model: Model | null; thinkingLevel: ThinkingLevel; entries: readonly SessionEntry[]; - messages: readonly AppMessage[]; - - // New: Utilities + + // Utilities findModel(provider: string, id: string): Model | null; availableModels(): Promise[]>; resolveApiKey(model: Model): Promise; - - // New: Mutation (commands only? or all?) + + // Mutation saveEntry(entry: { type: string; [k: string]: unknown }): Promise; rebuildContext(): Promise; } + +interface ContextMessage { + message: AppMessage; + entryIndex: number | null; // null = synthetic +} + +interface ContextEvent { + type: "context"; + entries: readonly SessionEntry[]; + messages: ContextMessage[]; +} ``` -Commands additionally get: -- `args: string[]`, `argsRaw: string` -- `setModel()`, `setThinkingLevel()` (state mutation) +Commands also get: `args`, `argsRaw`, `signal`, `setModel()`, `setThinkingLevel()`. -## Benchmark: Stacking as Hook +## Stacking: Design + +### Entry Format ```typescript +interface StackPopEntry { + type: "stack_pop"; + backToIndex: number; + summary: string; + prePopSummary?: string; // when crossing compaction + timestamp: number; +} +``` + +### Crossing Compaction + +Entries are never deleted. Raw data always available. + +When `backToIndex < compaction.firstKeptEntryIndex`: +1. Read raw entries `[0, backToIndex)` → summarize → `prePopSummary` +2. Read raw entries `[backToIndex, now)` → summarize → `summary` + +### Context Algorithm: Later Wins + +Assign sequential IDs to ranges. On overlap, highest ID wins. + +``` +Compaction at 40: range [0, 30) id=0 +StackPop at 50, backTo=20, prePopSummary: ranges [0, 20) id=1, [20, 50) id=2 + +Index 0-19: id=0 and id=1 cover → id=1 wins (prePopSummary) +Index 20-29: id=0 and id=2 cover → id=2 wins (popSummary) +Index 30-49: id=2 covers → id=2 (already emitted at 20) +Index 50+: no coverage → include as messages +``` + +## Complex Scenario Trace + +``` +Initial: [msg1, msg2, msg3, msg4, msg5] + idx: 1, 2, 3, 4, 5 + +Compaction triggers: + [msg1-5, compaction{firstKept:4, summary:C1}] + idx: 1-5, 6 + Context: [C1, msg4, msg5] + +User continues: + [..., compaction, msg4, msg5, msg6, msg7] + idx: 6, 4*, 5*, 7, 8 (* kept from before) + +User does /pop to msg2 (index 2): + - backTo=2 < firstKept=4 → crossing! + - prePopSummary: summarize raw [0,2) → P1 + - summary: summarize raw [2,8) → S1 + - save: stack_pop{backTo:2, summary:S1, prePopSummary:P1} at index 9 + + Ranges: + compaction [0,4) id=0 + prePopSummary [0,2) id=1 + popSummary [2,9) id=2 + + Context build: + idx 0: covered by id=0,1 → id=1 wins, emit P1 + idx 1: covered by id=0,1 → id=1 (already emitted) + idx 2: covered by id=0,2 → id=2 wins, emit S1 + idx 3-8: covered by id=0 or id=2 → id=2 (already emitted) + idx 9: stack_pop entry, skip + idx 10+: not covered, include as messages + + Result: [P1, S1, msg10+] + +User continues, another compaction: + [..., stack_pop, msg10, msg11, msg12, compaction{firstKept:11, summary:C2}] + idx: 9, 10, 11, 12, 13 + + Ranges: + compaction@6 [0,4) id=0 + prePopSummary [0,2) id=1 + popSummary [2,9) id=2 + compaction@13 [0,11) id=3 ← this now covers previous ranges! + + Context build: + idx 0-10: covered by multiple, id=3 wins → emit C2 at idx 0 + idx 11+: include as messages + + Result: [C2, msg11, msg12] + + C2's summary text includes info from P1 and S1 (they were in context when C2 was generated). +``` + +The "later wins" rule naturally handles all cases. + +## Core Changes + +| File | Change | +|------|--------| +| `session-manager.ts` | `saveEntry()`, `buildSessionContext()` returns `ContextMessage[]` | +| `hooks/types.ts` | `ContextEvent`, `ContextMessage`, extended context, command types | +| `hooks/loader.ts` | Track commands | +| `hooks/runner.ts` | `setStateCallbacks()`, `emitContext()`, command methods | +| `agent-session.ts` | `saveEntry()`, `rebuildContext()`, state callbacks | +| `interactive-mode.ts` | Command handling, autocomplete | + +## Stacking Hook: Complete Implementation + +```typescript +import { complete } from "@mariozechner/pi-ai"; +import type { HookAPI, AppMessage, SessionEntry, ContextMessage } from "@mariozechner/pi-coding-agent/hooks"; + export default function(pi: HookAPI) { - // Command: /pop pi.command("pop", { - description: "Pop to previous turn, summarizing substack", + description: "Pop to previous turn, summarizing work", handler: async (ctx) => { - // 1. Build turn list from entries - const turns = ctx.entries + const entries = ctx.entries as SessionEntry[]; + + // Get user turns + const turns = entries .map((e, i) => ({ e, i })) - .filter(({ e }) => e.type === "message" && e.message.role === "user") - .map(({ e, i }) => ({ index: i, text: e.message.content.slice(0, 50) })); + .filter(({ e }) => e.type === "message" && (e as any).message.role === "user") + .map(({ e, i }) => ({ idx: i, text: preview((e as any).message) })); - if (!turns.length) return { status: "No turns to pop" }; + if (turns.length < 2) return { status: "Need at least 2 turns" }; + + // Select target (skip last turn - that's current) + const options = turns.slice(0, -1).map(t => `[${t.idx}] ${t.text}`); + const selected = ctx.args[0] + ? options.find(o => o.startsWith(`[${ctx.args[0]}]`)) + : await ctx.ui.select("Pop to:", options); - // 2. User selects - const selected = await ctx.ui.select("Pop to:", turns.map(t => t.text)); if (!selected) return; - const backTo = turns.find(t => t.text === selected)!.index; + const backTo = parseInt(selected.match(/\[(\d+)\]/)![1]); - // 3. Summarize entries from backTo to now - const toSummarize = ctx.entries.slice(backTo) - .filter(e => e.type === "message") - .map(e => e.message); - const summary = await generateSummary(toSummarize, ctx); + // Check compaction crossing + const compactions = entries.filter(e => e.type === "compaction") as any[]; + const latestCompaction = compactions[compactions.length - 1]; + const crossing = latestCompaction && backTo < latestCompaction.firstKeptEntryIndex; - // 4. Save custom entry + // Generate summaries + let prePopSummary: string | undefined; + if (crossing) { + ctx.ui.notify("Crossing compaction, generating pre-pop summary...", "info"); + const preMsgs = getMessages(entries.slice(0, backTo)); + prePopSummary = await summarize(preMsgs, ctx, "context before this work"); + } + + const popMsgs = getMessages(entries.slice(backTo)); + const summary = await summarize(popMsgs, ctx, "completed work"); + + // Save and rebuild await ctx.saveEntry({ type: "stack_pop", backToIndex: backTo, summary, - timestamp: Date.now() + prePopSummary, }); - // 5. Rebuild await ctx.rebuildContext(); - return { status: "Popped stack" }; + return { status: `Popped to turn ${backTo}` }; } }); - - // Context transform: apply stack pops + pi.on("context", (event, ctx) => { - const pops = event.entries.filter(e => e.type === "stack_pop"); - if (!pops.length) return; // use default + const hasPops = event.entries.some(e => e.type === "stack_pop"); + if (!hasPops) return; - // Build exclusion set - const excluded = new Set(); - const summaryAt = new Map(); + // Collect ranges with IDs + let rangeId = 0; + const ranges: Array<{from: number; to: number; summary: string; id: number}> = []; - for (const pop of pops) { - const popIdx = event.entries.indexOf(pop); - for (let i = pop.backToIndex; i <= popIdx; i++) excluded.add(i); - summaryAt.set(pop.backToIndex, pop.summary); + for (let i = 0; i < event.entries.length; i++) { + const e = event.entries[i] as any; + if (e.type === "compaction") { + ranges.push({ from: 0, to: e.firstKeptEntryIndex, summary: e.summary, id: rangeId++ }); + } + if (e.type === "stack_pop") { + if (e.prePopSummary) { + ranges.push({ from: 0, to: e.backToIndex, summary: e.prePopSummary, id: rangeId++ }); + } + ranges.push({ from: e.backToIndex, to: i, summary: e.summary, id: rangeId++ }); + } } - // Build filtered messages - const messages: AppMessage[] = []; + // Build messages + const messages: ContextMessage[] = []; + const emitted = new Set(); + for (let i = 0; i < event.entries.length; i++) { - if (excluded.has(i)) continue; + const covering = ranges.filter(r => r.from <= i && i < r.to); - if (summaryAt.has(i)) { - messages.push({ - role: "user", - content: `[Subtask completed]\n\n${summaryAt.get(i)}`, - timestamp: Date.now() - }); + if (covering.length) { + const winner = covering.reduce((a, b) => a.id > b.id ? a : b); + if (i === winner.from && !emitted.has(winner.id)) { + messages.push({ + message: { role: "user", content: `[Summary]\n\n${winner.summary}`, timestamp: Date.now() } as AppMessage, + entryIndex: null + }); + emitted.add(winner.id); + } + continue; } const e = event.entries[i]; - if (e.type === "message") messages.push(e.message); + if (e.type === "message") { + messages.push({ message: (e as any).message, entryIndex: i }); + } } return { messages }; }); } -async function generateSummary(messages, ctx) { +function getMessages(entries: SessionEntry[]): AppMessage[] { + return entries.filter(e => e.type === "message").map(e => (e as any).message); +} + +function preview(msg: AppMessage): string { + const text = typeof msg.content === "string" ? msg.content + : (msg.content as any[]).filter(c => c.type === "text").map(c => c.text).join(" "); + return text.slice(0, 40) + (text.length > 40 ? "..." : ""); +} + +async function summarize(msgs: AppMessage[], ctx: any, purpose: string): Promise { const apiKey = await ctx.resolveApiKey(ctx.model); - // Call LLM for summary... + const resp = await complete(ctx.model, { + messages: [...msgs, { role: "user", content: `Summarize as "${purpose}". Be concise.`, timestamp: Date.now() }] + }, { apiKey, maxTokens: 2000, signal: ctx.signal }); + return resp.content.filter((c: any) => c.type === "text").map((c: any) => c.text).join("\n"); } ``` -## Core Changes Required +## Edge Cases -### session-manager.ts +### Session Resumed Without Hook +User has stacking hook, does `/pop`, saves `stack_pop` entry. Later removes hook and resumes session. + +**What happens:** +1. Core loads all entries (including `stack_pop`) +2. Core's `buildSessionContext()` ignores unknown types, returns compaction + message entries +3. `context` event fires, but no handler processes `stack_pop` +4. Core's messages pass through unchanged + +**Result:** Messages that were "popped" return to context. The pop is effectively undone. + +**Why this is OK:** +- Session file is intact, no data lost +- If compaction happened after pop, the compaction summary captured the popped state +- User removed the hook, so hook's behavior (hiding messages) is gone +- User can re-add hook to restore stacking behavior + +**Mitigation:** Could warn on session load if unknown entry types found: ```typescript -// Allow saving arbitrary entries -saveEntry(entry: { type: string; [k: string]: unknown }): void { - if (!entry.type) throw new Error("Entry must have type"); - this.inMemoryEntries.push(entry); - this._persist(entry); -} - -// buildSessionContext ignores unknown types (existing behavior works) -``` - -### hooks/types.ts - -```typescript -// New event -interface ContextEvent { - type: "context"; - entries: readonly SessionEntry[]; - messages: AppMessage[]; -} - -// Extended base context (see Unified Context above) - -// Command types -interface CommandOptions { - description?: string; - handler: (ctx: CommandContext) => Promise; -} - -type CommandResult = - | string - | { prompt: string; attachments?: Attachment[] } - | { status: string }; -``` - -### hooks/loader.ts - -```typescript -// Track registered commands -interface LoadedHook { - path: string; - handlers: Map; - commands: Map; // NEW -} - -// createHookAPI adds command() method -``` - -### hooks/runner.ts - -```typescript -class HookRunner { - // State callbacks (set by AgentSession) - setStateCallbacks(cb: StateCallbacks): void; - - // Command invocation - getCommands(): Map; - invokeCommand(name: string, argsRaw: string): Promise; - - // Context event with chaining - async emitContext(entries, messages): Promise { - let result = messages; - for (const hook of this.hooks) { - const handlers = hook.handlers.get("context"); - for (const h of handlers ?? []) { - const out = await h({ entries, messages: result }, this.createContext()); - if (out?.messages) result = out.messages; - } - } - return result; - } +// In session load +const unknownTypes = entries + .map(e => e.type) + .filter(t => !knownTypes.has(t)); +if (unknownTypes.length) { + console.warn(`Session has entries of unknown types: ${unknownTypes.join(", ")}`); } ``` -### agent-session.ts +### Hook Added to Existing Session -```typescript -// Expose saveEntry -async saveEntry(entry): Promise { - this.sessionManager.saveEntry(entry); -} +User has old session without stacking. Adds stacking hook, does `/pop`. -// Rebuild context -async rebuildContext(): Promise { - const base = this.sessionManager.buildSessionContext(); - const entries = this.sessionManager.getEntries(); - const messages = await this._hookRunner.emitContext(entries, base.messages); - this.agent.replaceMessages(messages); -} +**What happens:** +1. Hook saves `stack_pop` entry +2. `context` event fires, hook processes it +3. Works normally -// Fire context event during normal context building too -``` +No issue. Hook processes entries it recognizes, ignores others. -### interactive-mode.ts +### Multiple Hooks with Different Entry Types -```typescript -// In setupEditorSubmitHandler, check hook commands -const commands = this.session.hookRunner?.getCommands(); -if (commands?.has(commandName)) { - const result = await this.session.invokeCommand(commandName, argsRaw); - // Handle result... - return; -} +Hook A handles `type_a` entries, Hook B handles `type_b` entries. -// Add hook commands to autocomplete -``` +**What happens:** +1. `context` event chains through both hooks +2. Each hook checks for its entry types, passes through if none found +3. Each hook's transforms are applied in order -## Open Questions +**Best practice:** Hooks should: +- Only process their own entry types +- Return `undefined` (pass through) if no relevant entries +- Use prefixed type names: `myhook_pop`, `myhook_prune` -1. **Mutation in all handlers or commands only?** - - `saveEntry`/`rebuildContext` in all handlers = more power, more footguns - - Commands only = safer, but limits hook creativity - - Recommendation: start with commands only +### Conflicting Hooks -2. **Context event timing** - - Fire on every prompt? Or only when explicitly rebuilt? - - Need to fire on session load too - - Recommendation: fire whenever agent.replaceMessages is called +Two hooks both try to handle the same entry type (e.g., both handle `compaction`). -3. **Compaction interaction** - - Core compaction runs first, then `context` event - - Hooks can post-process compacted output - - Future: compaction itself could become a replaceable hook +**What happens:** +- Later hook (project > global) wins in the chain +- Earlier hook's transform is overwritten -4. **Multiple context handlers** - - Chain in load order (global → project) - - Each sees previous output - - No explicit priority system (KISS) +**Mitigation:** +- Core entry types (`compaction`, `message`, etc.) should not be overridden by hooks +- Hooks should use unique prefixed type names +- Document which types are "reserved" + +### Session with Future Entry Types + +User downgrades pi version, session has entry types from newer version. + +**What happens:** +- Same as "hook removed" - unknown types ignored +- Core handles what it knows, hooks handle what they know + +**Session file is forward-compatible:** Unknown entries are preserved in file, just not processed. + +## Implementation Phases + +| Phase | Scope | LOC | +|-------|-------|-----| +| v2.0 | `saveEntry`, `context` event, `rebuildContext`, extended context | ~150 | +| v2.1 | `pi.command()`, TUI integration, autocomplete | ~200 | +| v2.2 | Example hooks, documentation | ~300 | + +## Implementation Order + +1. `ContextMessage` type, update `buildSessionContext()` return type +2. `saveEntry()` in session-manager +3. `context` event in runner with chaining +4. State callbacks interface and wiring +5. `rebuildContext()` in agent-session +6. Manual test with simple hook +7. Command registration in loader +8. Command invocation in runner +9. TUI command handling + autocomplete +10. Stacking example hook +11. Pruning example hook +12. Update hooks.md diff --git a/packages/coding-agent/src/cli/list-models.ts b/packages/coding-agent/src/cli/list-models.ts index c677f828..59ef03da 100644 --- a/packages/coding-agent/src/cli/list-models.ts +++ b/packages/coding-agent/src/cli/list-models.ts @@ -4,6 +4,7 @@ import type { Api, Model } from "@mariozechner/pi-ai"; import { getAvailableModels } from "../core/model-config.js"; +import type { SettingsManager } from "../core/settings-manager.js"; import { fuzzyFilter } from "../utils/fuzzy.js"; /** @@ -24,8 +25,11 @@ function formatTokenCount(count: number): string { /** * List available models, optionally filtered by search pattern */ -export async function listModels(searchPattern?: string): Promise { - const { models, error } = await getAvailableModels(); +export async function listModels(searchPattern?: string, settingsManager?: SettingsManager): Promise { + const { models, error } = await getAvailableModels( + undefined, + settingsManager ? (provider) => settingsManager.getApiKey(provider) : undefined, + ); if (error) { console.error(error); diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index d02ee8b2..e4b9c2e6 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -616,7 +616,9 @@ export class AgentSession { } private async _cycleAvailableModel(): Promise { - const { models: availableModels, error } = await getAvailableModels(); + const { models: availableModels, error } = await getAvailableModels(undefined, (provider) => + this.settingsManager.getApiKey(provider), + ); if (error) throw new Error(`Failed to load models: ${error}`); if (availableModels.length <= 1) return null; @@ -648,7 +650,9 @@ export class AgentSession { * Get all available models with valid API keys. */ async getAvailableModels(): Promise[]> { - const { models, error } = await getAvailableModels(); + const { models, error } = await getAvailableModels(undefined, (provider) => + this.settingsManager.getApiKey(provider), + ); if (error) throw new Error(error); return models; } @@ -1330,7 +1334,9 @@ export class AgentSession { // Restore model if saved if (sessionContext.model) { - const availableModels = (await getAvailableModels()).models; + const availableModels = ( + await getAvailableModels(undefined, (provider) => this.settingsManager.getApiKey(provider)) + ).models; const match = availableModels.find( (m) => m.provider === sessionContext.model!.provider && m.id === sessionContext.model!.modelId, ); diff --git a/packages/coding-agent/src/core/model-config.ts b/packages/coding-agent/src/core/model-config.ts index 0dddc24b..3b1bf43c 100644 --- a/packages/coding-agent/src/core/model-config.ts +++ b/packages/coding-agent/src/core/model-config.ts @@ -358,9 +358,14 @@ export async function getApiKeyForModel(model: Model): Promise string | undefined, ): Promise<{ models: Model[]; error: string | null }> { const { models: allModels, error } = loadAndMergeModels(agentDir); @@ -370,7 +375,11 @@ export async function getAvailableModels( const availableModels: Model[] = []; for (const model of allModels) { - const apiKey = await getApiKeyForModel(model); + let apiKey = await getApiKeyForModel(model); + // Check fallback resolver if primary lookup failed + if (!apiKey && fallbackKeyResolver) { + apiKey = fallbackKeyResolver(model.provider); + } if (apiKey) { availableModels.push(model); } diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index 03c70380..7e11d9d6 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -167,9 +167,15 @@ export function parseModelPattern(pattern: string, availableModels: Model[] * Supports models with colons in their IDs (e.g., OpenRouter's model:exacto). * The algorithm tries to match the full pattern first, then progressively * strips colon-suffixes to find a match. + * + * @param patterns - Model patterns to resolve + * @param settingsManager - Optional settings manager for API key fallback from settings.json */ -export async function resolveModelScope(patterns: string[]): Promise { - const { models: availableModels, error } = await getAvailableModels(); +export async function resolveModelScope(patterns: string[], settingsManager?: SettingsManager): Promise { + const { models: availableModels, error } = await getAvailableModels( + undefined, + settingsManager ? (provider) => settingsManager.getApiKey(provider) : undefined, + ); if (error) { console.warn(chalk.yellow(`Warning: Error loading models: ${error}`)); @@ -269,7 +275,9 @@ export async function findInitialModel(options: { } // 4. Try first available model with valid API key - const { models: availableModels, error } = await getAvailableModels(); + const { models: availableModels, error } = await getAvailableModels(undefined, (provider) => + settingsManager.getApiKey(provider), + ); if (error) { console.error(chalk.red(error)); @@ -302,6 +310,7 @@ export async function restoreModelFromSession( savedModelId: string, currentModel: Model | null, shouldPrintMessages: boolean, + settingsManager?: SettingsManager, ): Promise<{ model: Model | null; fallbackMessage: string | null }> { const { model: restoredModel, error } = findModel(savedProvider, savedModelId); @@ -339,7 +348,10 @@ export async function restoreModelFromSession( } // Try to find any available model - const { models: availableModels, error: availableError } = await getAvailableModels(); + const { models: availableModels, error: availableError } = await getAvailableModels( + undefined, + settingsManager ? (provider) => settingsManager.getApiKey(provider) : undefined, + ); if (availableError) { console.error(chalk.red(availableError)); process.exit(1); diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 75349db3..c4e1b7fc 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -270,7 +270,8 @@ export async function main(args: string[]) { if (parsed.listModels !== undefined) { const searchPattern = typeof parsed.listModels === "string" ? parsed.listModels : undefined; - await listModels(searchPattern); + const settingsManager = SettingsManager.create(process.cwd()); + await listModels(searchPattern, settingsManager); return; } @@ -305,7 +306,7 @@ export async function main(args: string[]) { let scopedModels: ScopedModel[] = []; if (parsed.models && parsed.models.length > 0) { - scopedModels = await resolveModelScope(parsed.models); + scopedModels = await resolveModelScope(parsed.models, settingsManager); time("resolveModelScope"); } diff --git a/packages/coding-agent/src/modes/interactive/components/model-selector.ts b/packages/coding-agent/src/modes/interactive/components/model-selector.ts index 2639aa96..35b00f8e 100644 --- a/packages/coding-agent/src/modes/interactive/components/model-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/model-selector.ts @@ -114,7 +114,10 @@ export class ModelSelectorComponent extends Container { })); } else { // Load available models fresh (includes custom models from models.json) - const { models: availableModels, error } = await getAvailableModels(); + // Pass settings manager's key resolver as fallback for settings.json apiKeys + const { models: availableModels, error } = await getAvailableModels(undefined, (provider) => + this.settingsManager.getApiKey(provider), + ); // If there's an error loading models.json, we'll show it via the "no models" path // The error will be displayed to the user