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:
parent
1db8b21a83
commit
d7eb0da49b
3 changed files with 218 additions and 3 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue