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 <noreply@anthropic.com>
This commit is contained in:
Jiang Bohan 2026-02-04 18:25:06 +08:00
parent 1db8b21a83
commit d7eb0da49b
3 changed files with 218 additions and 3 deletions

View file

@ -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);
}
}

View file

@ -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();

View file

@ -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.