From d7eb0da49b894692e94bdab7562c1be9db2d6ea0 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Wed, 4 Feb 2026 18:25:06 +0800 Subject: [PATCH] feat(agent): add provider switching and OAuth credential support - Add getProviderInfo() and setProvider() methods to Agent class - Expose provider methods via AsyncAgent - Add setLlmProviderOAuthToken() for storing OAuth credentials - Extend ProviderConfig type with OAuth fields (oauthToken, oauthRefreshToken, oauthExpiresAt) Co-Authored-By: Claude Opus 4.5 --- src/agent/async-agent.ts | 15 +++++ src/agent/credentials.ts | 136 ++++++++++++++++++++++++++++++++++++++- src/agent/runner.ts | 70 +++++++++++++++++++- 3 files changed, 218 insertions(+), 3 deletions(-) diff --git a/src/agent/async-agent.ts b/src/agent/async-agent.ts index 68475555..4fdfb616 100644 --- a/src/agent/async-agent.ts +++ b/src/agent/async-agent.ts @@ -221,4 +221,19 @@ export class AsyncAgent { getMessages(): AgentMessage[] { return this.agent.getMessages(); } + + /** + * Get current provider and model information. + */ + getProviderInfo(): { provider: string; model: string | undefined } { + return this.agent.getProviderInfo(); + } + + /** + * Switch to a different provider and/or model. + * This updates the agent's model without recreating the session. + */ + setProvider(providerId: string, modelId?: string): { provider: string; model: string | undefined } { + return this.agent.setProvider(providerId, modelId); + } } diff --git a/src/agent/credentials.ts b/src/agent/credentials.ts index 5f4c7555..223798e6 100644 --- a/src/agent/credentials.ts +++ b/src/agent/credentials.ts @@ -1,11 +1,17 @@ -import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { join, dirname } from "node:path"; import { homedir } from "node:os"; import JSON5 from "json5"; import { DATA_DIR } from "../shared/paths.js"; type ProviderConfig = { + // API Key authentication apiKey?: string | undefined; + // OAuth authentication + oauthToken?: string | undefined; + oauthRefreshToken?: string | undefined; + oauthExpiresAt?: number | undefined; + // Common baseUrl?: string | undefined; model?: string | undefined; }; @@ -223,6 +229,132 @@ export class CredentialManager { this.skillsConfig = null; this.resolvedSkillsEnv = null; } + + /** + * Set the API key for a provider and save to credentials.json5. + * Creates the file if it doesn't exist. + */ + setLlmProviderApiKey(provider: string, apiKey: string): void { + const path = getCredentialsPath(); + + // Load existing config or create new one + let config: CredentialsConfig = { version: 1 }; + if (existsSync(path)) { + try { + const raw = readFileSync(path, "utf8"); + config = JSON5.parse(raw) as CredentialsConfig; + } catch { + // If parse fails, start fresh + config = { version: 1 }; + } + } + + // Ensure structure exists + if (!config.llm) { + config.llm = {}; + } + if (!config.llm.providers) { + config.llm.providers = {}; + } + + // Set or update the provider config + const existing = config.llm.providers[provider] ?? {}; + config.llm.providers[provider] = { + ...existing, + apiKey, + }; + + // Write back to file + mkdirSync(dirname(path), { recursive: true }); + const content = JSON.stringify(config, null, 2); + writeFileSync(path, content, "utf8"); + + // Reset cache so next read picks up the change + this.reset(); + } + + /** + * Set OAuth token for a provider and save to credentials.json5. + * Used for OAuth providers like claude-code and openai-codex. + */ + setLlmProviderOAuthToken( + provider: string, + token: string, + refreshToken?: string, + expiresAt?: number, + ): void { + const path = getCredentialsPath(); + + // Load existing config or create new one + let config: CredentialsConfig = { version: 1 }; + if (existsSync(path)) { + try { + const raw = readFileSync(path, "utf8"); + config = JSON5.parse(raw) as CredentialsConfig; + } catch { + config = { version: 1 }; + } + } + + // Ensure structure exists + if (!config.llm) { + config.llm = {}; + } + if (!config.llm.providers) { + config.llm.providers = {}; + } + + // Set or update the provider config + const existing = config.llm.providers[provider] ?? {}; + config.llm.providers[provider] = { + ...existing, + oauthToken: token, + oauthRefreshToken: refreshToken, + oauthExpiresAt: expiresAt, + }; + + // Write back to file + mkdirSync(dirname(path), { recursive: true }); + const content = JSON.stringify(config, null, 2); + writeFileSync(path, content, "utf8"); + + // Reset cache + this.reset(); + } + + /** + * Set the default LLM provider and save to credentials.json5. + */ + setDefaultLlmProvider(provider: string): void { + const path = getCredentialsPath(); + + // Load existing config or create new one + let config: CredentialsConfig = { version: 1 }; + if (existsSync(path)) { + try { + const raw = readFileSync(path, "utf8"); + config = JSON5.parse(raw) as CredentialsConfig; + } catch { + config = { version: 1 }; + } + } + + // Ensure structure exists + if (!config.llm) { + config.llm = {}; + } + + // Set default provider + config.llm.provider = provider; + + // Write back to file + mkdirSync(dirname(path), { recursive: true }); + const content = JSON.stringify(config, null, 2); + writeFileSync(path, content, "utf8"); + + // Reset cache + this.reset(); + } } export const credentialManager = new CredentialManager(); diff --git a/src/agent/runner.ts b/src/agent/runner.ts index f66ef159..98f233d9 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -9,6 +9,8 @@ import { resolveApiKeyForProvider, resolveBaseUrl, resolveModelId, + PROVIDER_ALIAS, + getDefaultModel, } from "./providers/index.js"; import { SessionManager } from "./session/session-manager.js"; import { ProfileManager } from "./profile/index.js"; @@ -82,7 +84,7 @@ export class Agent { private initialized = false; // Auth profile rotation state - private readonly resolvedProvider: string; + private resolvedProvider: string; private currentApiKey: string | undefined; private currentProfileId: string | undefined; private profileCandidates: string[]; @@ -598,6 +600,72 @@ export class Agent { this.profile?.updateStyle(style); } + /** + * Get current provider and model information. + */ + getProviderInfo(): { provider: string; model: string | undefined } { + return { + provider: this.resolvedProvider, + model: this.agent.state.model?.id, + }; + } + + /** + * Switch to a different provider and/or model. + * This updates the agent's model without recreating the session. + */ + setProvider(providerId: string, modelId?: string): { provider: string; model: string | undefined } { + // Resolve the actual provider (handle aliases like claude-code -> anthropic) + const actualProvider = PROVIDER_ALIAS[providerId] ?? providerId; + + // Resolve the model + const targetModel = modelId ?? getDefaultModel(providerId) ?? getDefaultModel(actualProvider); + const model = resolveModel({ provider: providerId, model: targetModel }); + + if (!model) { + throw new Error(`Failed to resolve model for provider: ${providerId}, model: ${targetModel}`); + } + + // Resolve API key for the new provider + // For OAuth providers (claude-code, openai-codex), we need to use the original providerId + // because OAuth credentials are resolved by the original provider name, not the alias + const resolved = resolveApiKeyForProvider(providerId); + if (resolved) { + this.currentApiKey = resolved.apiKey; + this.currentProfileId = resolved.profileId; + } else { + // Fallback: try with actual provider (for API key based providers) + this.currentApiKey = resolveApiKey(actualProvider); + this.currentProfileId = actualProvider; + } + + if (!this.currentApiKey) { + throw new Error(`No API key configured for provider: ${providerId}`); + } + + // Update the agent's model and API key + const baseUrl = resolveBaseUrl(actualProvider); + const modelWithBaseUrl = baseUrl ? { ...model, baseUrl } : model; + this.agent.setModel(modelWithBaseUrl); + + // Update internal state + this.resolvedProvider = providerId; + + // Update session metadata + this.session.saveMeta({ + provider: actualProvider, + model: model.id, + thinkingLevel: this.agent.state.thinkingLevel, + reasoningMode: this.reasoningMode, + contextWindowTokens: this.contextWindowGuard.tokens, + }); + + return { + provider: providerId, + model: model.id, + }; + } + /** * Build the full system prompt using the structured builder. * Combines profile content, tools, skills, and runtime info.