feat(backends): add codex/gemini external runners and wire backend selection

This commit is contained in:
William Valentin
2026-02-17 09:26:21 -08:00
parent 2273ffd020
commit 1d59becfa5
4 changed files with 95 additions and 0 deletions
+15
View File
@@ -316,6 +316,21 @@ 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`.
### Agent Backends
Flynn can run with the built-in native backend or delegate message processing to external CLI backends.
```yaml
backends:
native: { enabled: true }
codex: { enabled: false, path: /usr/local/bin/codex }
claude_code: { enabled: false, path: /usr/local/bin/claude }
opencode: { enabled: false, path: /usr/local/bin/opencode }
gemini: { enabled: false, path: /usr/local/bin/gemini }
```
If multiple external backends are enabled, Flynn selects the first in this order: `codex` -> `claude_code` -> `opencode` -> `gemini`.
### Native Audio Support
Voice messages from channels can be handled in two ways:
+32
View File
@@ -196,6 +196,38 @@ describe('configSchema — server', () => {
});
});
describe('configSchema — backends', () => {
const minimalConfig = {
telegram: { bot_token: 'test', allowed_chat_ids: [1] },
models: { default: { provider: 'anthropic', model: 'claude-3' } },
};
it('defaults backend flags to native enabled and externals disabled', () => {
const result = configSchema.parse(minimalConfig);
expect(result.backends.native.enabled).toBe(true);
expect(result.backends.claude_code.enabled).toBe(false);
expect(result.backends.opencode.enabled).toBe(false);
expect(result.backends.codex.enabled).toBe(false);
expect(result.backends.gemini.enabled).toBe(false);
});
it('accepts explicit external backend configs', () => {
const result = configSchema.parse({
...minimalConfig,
backends: {
native: { enabled: false },
codex: { enabled: true, path: '/usr/local/bin/codex' },
gemini: { enabled: true, path: '/usr/local/bin/gemini' },
},
});
expect(result.backends.native.enabled).toBe(false);
expect(result.backends.codex.enabled).toBe(true);
expect(result.backends.codex.path).toBe('/usr/local/bin/codex');
expect(result.backends.gemini.enabled).toBe(true);
expect(result.backends.gemini.path).toBe('/usr/local/bin/gemini');
});
});
describe('configSchema — browser', () => {
const minimalConfig = {
telegram: { bot_token: 'test', allowed_chat_ids: [1] },
+8
View File
@@ -195,6 +195,14 @@ const backendsSchema = z.object({
args: z.array(z.string()).default([]),
timeout_ms: z.number().min(1_000).max(600_000).default(120_000),
}).default({ enabled: false }),
codex: z.object({
enabled: z.boolean().default(false),
path: z.string().optional(),
}).default({ enabled: false }),
gemini: z.object({
enabled: z.boolean().default(false),
path: z.string().optional(),
}).default({ enabled: false }),
native: z.object({
enabled: z.boolean().default(true),
}).default({ enabled: true }),
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { configSchema } from '../config/schema.js';
import { createConfiguredExternalBackend } from './index.js';
describe('createConfiguredExternalBackend', () => {
const base = configSchema.parse({
telegram: { bot_token: 'test', allowed_chat_ids: [1] },
models: { default: { provider: 'anthropic', model: 'claude-3' } },
});
it('returns undefined when no external backend is enabled', () => {
const backend = createConfiguredExternalBackend(base);
expect(backend).toBeUndefined();
});
it('selects codex when enabled', () => {
const cfg = {
...base,
backends: {
...base.backends,
codex: { enabled: true, path: '/usr/bin/codex' },
},
};
const backend = createConfiguredExternalBackend(cfg);
expect(backend?.name).toBe('codex');
});
it('selects gemini when enabled and higher-priority backends are disabled', () => {
const cfg = {
...base,
backends: {
...base.backends,
gemini: { enabled: true, path: '/usr/bin/gemini' },
},
};
const backend = createConfiguredExternalBackend(cfg);
expect(backend?.name).toBe('gemini');
});
});