diff --git a/README.md b/README.md index f71f7a8..fd1db2a 100644 --- a/README.md +++ b/README.md @@ -253,6 +253,7 @@ models: provider: anthropic model: claude-opus-4-5-20251101 api_key: sk-ant-api03-... + # api_keys: [sk-ant-primary-..., sk-ant-secondary-...] # Optional rotation pool local: provider: ollama model: qwen2.5:14b @@ -289,17 +290,17 @@ If you want a fast mental model of where to start as an AI agent / contributor: | Provider | Config | |----------|--------| -| Anthropic | `provider: anthropic`, `api_key` or `auth_token` | -| OpenAI | `provider: openai`, `api_key`, optional `endpoint` | +| Anthropic | `provider: anthropic`, `api_key`/`api_keys` or `auth_token` | +| OpenAI | `provider: openai`, `api_key`/`api_keys`, optional `endpoint` | | Vercel AI Gateway | `provider: vercel`, `api_key` or `AI_GATEWAY_API_KEY`, optional `endpoint` | | GitHub Copilot | `provider: github`, auto-login via OAuth device flow | | Gemini | `provider: gemini`, `api_key` | | Bedrock | `provider: bedrock`, AWS credentials | | Ollama | `provider: ollama`, `model`, optional `endpoint` | | Zhipu AI (GLM) | `provider: zhipuai`, `api_key` or `ZHIPUAI_API_KEY`, optional `endpoint` | -| xAI (Grok) | `provider: xai`, `api_key` or `XAI_API_KEY` | -| MiniMax | `provider: minimax`, `api_key` or `MINIMAX_API_KEY`, optional `endpoint` | -| Moonshot (Kimi) | `provider: moonshot`, `api_key` or `MOONSHOT_API_KEY`, optional `endpoint` | +| xAI (Grok) | `provider: xai`, `api_key`/`api_keys` or `XAI_API_KEY` | +| MiniMax | `provider: minimax`, `api_key`/`api_keys` or `MINIMAX_API_KEY`, optional `endpoint` | +| Moonshot (Kimi) | `provider: moonshot`, `api_key`/`api_keys` or `MOONSHOT_API_KEY`, optional `endpoint` | | llama.cpp | `provider: llamacpp`, `endpoint` | ### Model Tiers @@ -316,6 +317,8 @@ models: Each tier can optionally specify `auth_mode` (`auto` | `api_key` | `oauth`) to control whether Flynn uses API keys vs OAuth/token auth for that provider. `use_oauth: true` remains supported as a compatibility alias for `auth_mode: oauth`. +When multiple keys are configured via `api_keys`, Flynn rotates across keys on provider failures and sticks to the last successful key profile until it fails. + Note: with `provider: openai` + `auth_mode: oauth` (Codex backend), Flynn currently does not send tool definitions to the provider. Tool execution is therefore unavailable in that mode, and any textual `tool_use` output should be treated as non-executable model text. Note: with `provider: ollama`, tool execution depends on model capabilities. If Ollama reports that the selected model does not support tools, Flynn omits tool definitions for that request. diff --git a/config/default.yaml b/config/default.yaml index a5ecd4e..68f1985 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -150,6 +150,7 @@ models: model: claude-sonnet-4-20250514 # auth_mode: auto # auto | api_key | oauth (provider-specific) # use_oauth: false # compat alias for auth_mode: oauth + # api_keys: ["${ANTHROPIC_API_KEY_PRIMARY}", "${ANTHROPIC_API_KEY_SECONDARY}"] # Optional rotation pool # supports_audio: false # Override native audio detection per tier fast: provider: anthropic diff --git a/docs/plans/state.json b/docs/plans/state.json index 35199db..74225fd 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -5264,6 +5264,25 @@ "docs/plans/state.json" ], "test_status": "pnpm test:run src/automation/reactions.test.ts src/config/schema.test.ts src/daemon/routing.test.ts + pnpm typecheck passing" + }, + "model-auth-profile-rotation-tier-a5": { + "status": "completed", + "date": "2026-02-18", + "updated": "2026-02-18", + "summary": "Implemented Tier A5 auth-profile rotation with optional `api_keys` pools on model tiers/providers. Added rotating sticky-success client behavior (`RotatingModelClient`) and wired multi-key fallback for Anthropic/OpenAI/OpenRouter/xAI/MiniMax/Moonshot client creation, with schema/factory/model tests and docs updates.", + "files_modified": [ + "src/models/rotating.ts", + "src/models/rotating.test.ts", + "src/models/index.ts", + "src/config/schema.ts", + "src/config/schema.test.ts", + "src/daemon/models.ts", + "src/daemon/clientFactory.test.ts", + "README.md", + "config/default.yaml", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/models/rotating.test.ts src/daemon/clientFactory.test.ts src/config/schema.test.ts + pnpm typecheck passing" } }, "overall_progress": { @@ -5287,7 +5306,7 @@ "gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram", "native_audio_support": "completed — smart routing for native audio (Gemini/OpenAI/GitHub) vs Whisper transcription fallback", "remaining_phases_completion": "Phase 1: 3/3 (100%) — context levels, command registry, memory structure. Phase 2: 3/3 (100%) — component registry, confidence routing, history index. Phase 3: 2/2 (100%) — adaptive memory/compaction, truthfulness/autonomy hardening", - "next_up": "Implement Tier A5 model auth-profile rotation (multiple API keys per provider with session stickiness)" + "next_up": "Implement Tier B2 workflow approval gates (await-approval pattern across channels)" }, "soul_md_and_cron_create": { "date": "2026-02-11", diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 7dee0eb..4ed5ec2 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -550,6 +550,20 @@ describe('configSchema — models auth_mode', () => { }); expect(moonshot.models.default.provider).toBe('moonshot'); }); + + it('accepts multiple api_keys per model tier', () => { + const result = configSchema.parse({ + ...minimalConfig, + models: { + default: { + provider: 'openai', + model: 'gpt-4o', + api_keys: ['sk-1', 'sk-2'], + }, + }, + }); + expect(result.models.default.api_keys).toEqual(['sk-1', 'sk-2']); + }); }); describe('configSchema — matrix', () => { diff --git a/src/config/schema.ts b/src/config/schema.ts index 0e315e9..4018720 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -132,6 +132,7 @@ const modelConfigBaseSchema = z.object({ model: z.string(), endpoint: z.string().optional(), api_key: z.string().optional(), + api_keys: z.array(z.string().min(1)).optional(), auth_token: z.string().optional(), /** Credential selection strategy for this tier (provider-specific). */ auth_mode: z.enum(['auto', 'api_key', 'oauth']).optional(), diff --git a/src/daemon/clientFactory.test.ts b/src/daemon/clientFactory.test.ts index 34e6d1e..6240752 100644 --- a/src/daemon/clientFactory.test.ts +++ b/src/daemon/clientFactory.test.ts @@ -28,6 +28,16 @@ describe('createClientFromConfig', () => { expect(client.constructor.name).toBe('AnthropicClient'); }); + it('creates RotatingModelClient for anthropic when api_keys are provided', async () => { + const { createClientFromConfig } = await loadFactory(); + const client = createClientFromConfig({ + provider: 'anthropic', + model: 'claude-sonnet-4-5-20250514', + api_keys: ['sk-ant-1', 'sk-ant-2'], + }); + expect(client.constructor.name).toBe('RotatingModelClient'); + }); + it('creates OpenAIClient for openai provider', async () => { const { createClientFromConfig } = await loadFactory(); const client = createClientFromConfig({ @@ -38,6 +48,16 @@ describe('createClientFromConfig', () => { expect(client.constructor.name).toBe('OpenAIClient'); }); + it('creates RotatingModelClient for openai when api_keys are provided', async () => { + const { createClientFromConfig } = await loadFactory(); + const client = createClientFromConfig({ + provider: 'openai', + model: 'gpt-4o', + api_keys: ['sk-1', 'sk-2'], + }); + expect(client.constructor.name).toBe('RotatingModelClient'); + }); + it('creates OllamaClient for ollama provider', async () => { const { createClientFromConfig } = await loadFactory(); const client = createClientFromConfig({ diff --git a/src/daemon/models.ts b/src/daemon/models.ts index fca69ed..99a444d 100644 --- a/src/daemon/models.ts +++ b/src/daemon/models.ts @@ -1,5 +1,5 @@ import type { Config, ModelConfig } from '../config/index.js'; -import { AnthropicClient, OpenAIClient, OllamaClient, LlamaCppClient, GeminiClient, BedrockClient, GitHubModelsClient, SyntheticClient, ModelRouter, DEFAULT_RETRY_CONFIG } from '../models/index.js'; +import { AnthropicClient, OpenAIClient, OllamaClient, LlamaCppClient, GeminiClient, BedrockClient, GitHubModelsClient, SyntheticClient, RotatingModelClient, ModelRouter, DEFAULT_RETRY_CONFIG } from '../models/index.js'; import type { ModelClient, RetryConfig, ModelTier } from '../models/index.js'; import { logger } from '../logger.js'; import { getZaiApiKey } from '../auth/zai.js'; @@ -33,6 +33,30 @@ function requireApiKey(cfg: ModelConfig, envVar: string): string { return key; } +function resolveApiKeyPool(cfg: ModelConfig, envVar?: string): string[] { + const configured = (cfg.api_keys ?? []).map((key) => key.trim()).filter(Boolean); + if (configured.length > 0) { + return configured; + } + if (cfg.api_key?.trim()) { + return [cfg.api_key.trim()]; + } + if (envVar && process.env[envVar]?.trim()) { + return [process.env[envVar]!.trim()]; + } + return []; +} + +function createApiKeyClient( + keys: string[], + build: (apiKey: string) => ModelClient, +): ModelClient { + if (keys.length === 1) { + return build(keys[0]); + } + return new RotatingModelClient(keys.map((key) => build(key))); +} + function resolveZaiCredential(cfg: ModelConfig): string { const raw = cfg.api_key ?? cfg.auth_token @@ -75,26 +99,34 @@ export function createClientFromConfig(cfg: ModelConfig): ModelClient { } if (authMode === 'api_key') { - const apiKey = cfg.api_key ?? getAnthropicApiKey(); - if (!apiKey) { + const keys = resolveApiKeyPool(cfg); + const envKey = getAnthropicApiKey(); + const allKeys = keys.length > 0 + ? keys + : (envKey ? [envKey] : []); + if (allKeys.length === 0) { throw new Error( 'Anthropic API key not configured (auth_mode: api_key). ' + - 'Set ANTHROPIC_API_KEY, run `flynn anthropic-auth`, or provide api_key in config.', + 'Set ANTHROPIC_API_KEY, run `flynn anthropic-auth`, or provide api_key/api_keys in config.', ); } - return new AnthropicClient({ + return createApiKeyClient(allKeys, (apiKey) => new AnthropicClient({ model: cfg.model, apiKey, - }); + })); } - // auto: prefer API key, then token - const apiKey = cfg.api_key ?? getAnthropicApiKey(); - if (apiKey) { - return new AnthropicClient({ + // auto: prefer API keys, then token + const configuredKeys = resolveApiKeyPool(cfg); + const envKey = getAnthropicApiKey(); + const allKeys = configuredKeys.length > 0 + ? configuredKeys + : (envKey ? [envKey] : []); + if (allKeys.length > 0) { + return createApiKeyClient(allKeys, (apiKey) => new AnthropicClient({ model: cfg.model, apiKey, - }); + })); } const token = cfg.auth_token ?? getAnthropicAuthToken(); @@ -130,26 +162,34 @@ export function createClientFromConfig(cfg: ModelConfig): ModelClient { } if (authMode === 'api_key') { - const apiKey = cfg.api_key ?? getOpenAIApiKey(); - if (!apiKey) { + const keys = resolveApiKeyPool(cfg); + const envKey = getOpenAIApiKey(); + const allKeys = keys.length > 0 + ? keys + : (envKey ? [envKey] : []); + if (allKeys.length === 0) { throw new Error( 'OpenAI API key not configured (auth_mode: api_key). ' + - 'Set OPENAI_API_KEY, run `flynn openai-key`, or provide api_key in config.', + 'Set OPENAI_API_KEY, run `flynn openai-key`, or provide api_key/api_keys in config.', ); } - return new OpenAIClient({ + return createApiKeyClient(allKeys, (apiKey) => new OpenAIClient({ model: cfg.model, apiKey, - }); + })); } - // auto: prefer API key, then OAuth - const apiKey = cfg.api_key ?? getOpenAIApiKey(); - if (apiKey) { - return new OpenAIClient({ + // auto: prefer API keys, then OAuth + const configuredKeys = resolveApiKeyPool(cfg); + const envKey = getOpenAIApiKey(); + const allKeys = configuredKeys.length > 0 + ? configuredKeys + : (envKey ? [envKey] : []); + if (allKeys.length > 0) { + return createApiKeyClient(allKeys, (apiKey) => new OpenAIClient({ model: cfg.model, apiKey, - }); + })); } const existing = loadStoredOpenAIAuth(); @@ -184,11 +224,19 @@ export function createClientFromConfig(cfg: ModelConfig): ModelClient { apiKey: cfg.api_key, }); case 'openrouter': - return new OpenAIClient({ + { + const keys = resolveApiKeyPool(cfg, 'OPENROUTER_API_KEY'); + if (keys.length === 0) { + throw new Error( + 'API key required for openrouter. Set OPENROUTER_API_KEY or provide api_key/api_keys in config.', + ); + } + return createApiKeyClient(keys, (apiKey) => new OpenAIClient({ model: cfg.model, - apiKey: requireApiKey(cfg, 'OPENROUTER_API_KEY'), + apiKey, baseURL: cfg.endpoint ?? 'https://openrouter.ai/api/v1', - }); + })); + } case 'vercel': return new OpenAIClient({ model: cfg.model, @@ -202,23 +250,47 @@ export function createClientFromConfig(cfg: ModelConfig): ModelClient { baseURL: cfg.endpoint ?? 'https://api.z.ai/api/paas/v4', }); case 'xai': - return new OpenAIClient({ + { + const keys = resolveApiKeyPool(cfg, 'XAI_API_KEY'); + if (keys.length === 0) { + throw new Error( + 'API key required for xai. Set XAI_API_KEY or provide api_key/api_keys in config.', + ); + } + return createApiKeyClient(keys, (apiKey) => new OpenAIClient({ model: cfg.model, - apiKey: requireApiKey(cfg, 'XAI_API_KEY'), + apiKey, baseURL: cfg.endpoint ?? 'https://api.x.ai/v1', - }); + })); + } case 'minimax': - return new OpenAIClient({ + { + const keys = resolveApiKeyPool(cfg, 'MINIMAX_API_KEY'); + if (keys.length === 0) { + throw new Error( + 'API key required for minimax. Set MINIMAX_API_KEY or provide api_key/api_keys in config.', + ); + } + return createApiKeyClient(keys, (apiKey) => new OpenAIClient({ model: cfg.model, - apiKey: requireApiKey(cfg, 'MINIMAX_API_KEY'), + apiKey, baseURL: cfg.endpoint ?? 'https://api.minimax.io/v1', - }); + })); + } case 'moonshot': - return new OpenAIClient({ + { + const keys = resolveApiKeyPool(cfg, 'MOONSHOT_API_KEY'); + if (keys.length === 0) { + throw new Error( + 'API key required for moonshot. Set MOONSHOT_API_KEY or provide api_key/api_keys in config.', + ); + } + return createApiKeyClient(keys, (apiKey) => new OpenAIClient({ model: cfg.model, - apiKey: requireApiKey(cfg, 'MOONSHOT_API_KEY'), + apiKey, baseURL: cfg.endpoint ?? 'https://api.moonshot.cn/v1', - }); + })); + } case 'bedrock': return new BedrockClient({ model: cfg.model, diff --git a/src/models/index.ts b/src/models/index.ts index 3190079..193057e 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -7,6 +7,7 @@ export { SyntheticClient, type SyntheticClientConfig } from './synthetic.js'; export { OllamaClient, type OllamaClientConfig } from './local/index.js'; export { LlamaCppClient, type LlamaCppClientConfig } from './local/index.js'; export { ModelRouter, type ModelRouterConfig, type ModelTier } from './router.js'; +export { RotatingModelClient } from './rotating.js'; export { withRetry, isRetryable, DEFAULT_RETRY_CONFIG, type RetryConfig } from './retry.js'; export { estimateCost, MODEL_COSTS_PER_MILLION } from './costs.js'; export { supportsAudioInput } from './capabilities.js'; diff --git a/src/models/rotating.test.ts b/src/models/rotating.test.ts new file mode 100644 index 0000000..b62b7e2 --- /dev/null +++ b/src/models/rotating.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { ModelClient } from './types.js'; +import { RotatingModelClient } from './rotating.js'; + +function makeClient(chatImpl: ModelClient['chat']): ModelClient { + return { chat: chatImpl }; +} + +describe('RotatingModelClient', () => { + it('throws when created with no clients', () => { + expect(() => new RotatingModelClient([])).toThrow(/at least one client/i); + }); + + it('falls through to the next profile when the first fails', async () => { + const first = makeClient(vi.fn().mockRejectedValue(new Error('rate limited'))); + const second = makeClient(vi.fn().mockResolvedValue({ content: 'ok' })); + const rotating = new RotatingModelClient([first, second]); + + const response = await rotating.chat({ messages: [{ role: 'user', content: 'hello' }] }); + expect(response.content).toBe('ok'); + expect(first.chat).toHaveBeenCalledTimes(1); + expect(second.chat).toHaveBeenCalledTimes(1); + }); + + it('sticks to the last successful profile until it fails', async () => { + const first = makeClient(vi.fn().mockRejectedValue(new Error('429'))); + const second = makeClient(vi.fn().mockResolvedValue({ content: 'ok' })); + const rotating = new RotatingModelClient([first, second]); + + await rotating.chat({ messages: [{ role: 'user', content: 'a' }] }); + await rotating.chat({ messages: [{ role: 'user', content: 'b' }] }); + + expect(first.chat).toHaveBeenCalledTimes(1); + expect(second.chat).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/models/rotating.ts b/src/models/rotating.ts new file mode 100644 index 0000000..2d67be6 --- /dev/null +++ b/src/models/rotating.ts @@ -0,0 +1,64 @@ +import type { ChatRequest, ChatResponse, ChatStreamEvent, ModelClient } from './types.js'; + +/** + * Model client wrapper that rotates across equivalent auth profiles (e.g. API keys). + * Sticky-by-success behavior: keep using the last successful profile until it fails. + */ +export class RotatingModelClient implements ModelClient { + private readonly clients: ModelClient[]; + private currentIndex = 0; + + constructor(clients: ModelClient[]) { + if (clients.length === 0) { + throw new Error('RotatingModelClient requires at least one client'); + } + this.clients = clients; + } + + async chat(request: ChatRequest): Promise { + const start = this.currentIndex; + const errors: Error[] = []; + + for (let offset = 0; offset < this.clients.length; offset += 1) { + const index = (start + offset) % this.clients.length; + const client = this.clients[index]; + try { + const response = await client.chat(request); + this.currentIndex = index; + return response; + } catch (error) { + errors.push(error instanceof Error ? error : new Error(String(error))); + } + } + + throw new Error(`All auth profiles failed: ${errors.map((e) => e.message).join(', ')}`); + } + + async *chatStream(request: ChatRequest): AsyncIterable { + const start = this.currentIndex; + + for (let offset = 0; offset < this.clients.length; offset += 1) { + const index = (start + offset) % this.clients.length; + const client = this.clients[index]; + if (!client.chatStream) { + continue; + } + + let failed = false; + for await (const event of client.chatStream(request)) { + if (event.type === 'error') { + failed = true; + break; + } + yield event; + } + + if (!failed) { + this.currentIndex = index; + return; + } + } + + yield { type: 'error', error: new Error('All auth profiles failed for streaming') }; + } +}