feat: add multi-key auth profile rotation for model providers
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+20
-1
@@ -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",
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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({
|
||||
|
||||
+105
-33
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<ChatResponse> {
|
||||
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<ChatStreamEvent> {
|
||||
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') };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user