Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,7 @@ async function main(): Promise<number> {
baseURL: cfg.base_url,
apiKey: cfg.api_key,
model: cfg.model,
customProviders: cfg.custom_providers,
}),
persistDisabledSkills: async (names: string[]) => {
cfg.disabled_skills = [...names].sort();
Expand All @@ -715,6 +716,9 @@ async function main(): Promise<number> {
cfg.model = change.model;
if (change.baseURL !== undefined) cfg.base_url = change.baseURL;
if (change.apiKey !== undefined) cfg.api_key = change.apiKey;
if (change.customProvider) {
cfg.custom_providers.push(change.customProvider);
}
const next = llmFactory.newFromConfig(cfg);
agent.setClient(next);
agent.setAutoCompactThreshold(effectiveAutoCompactThreshold(cfg));
Expand Down
8 changes: 8 additions & 0 deletions src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ export type PluginConfig = z.infer<typeof PluginConfig>;
const ToolingProfile = z.enum(['minimal', 'full']);
export type ToolingProfile = z.infer<typeof ToolingProfile>;

const CustomProvider = z.object({
name: z.string().min(1),
base_url: z.string().min(1),
api_key: z.string().default(''),
});
export type CustomProvider = z.infer<typeof CustomProvider>;

/** Schema default for auto_compact_threshold. Exported so backend-specific
* overrides (e.g. large-context Kimi models) can detect "user is on the
* default" and size the threshold to the model's real context window. */
Expand Down Expand Up @@ -100,6 +107,7 @@ const ConfigSchema = z.object({
// Undefined means the user hasn't been asked yet — the CLI triggers a
// one-time first-run picker in that case and writes the answer back.
tooling_profile: ToolingProfile.optional(),
custom_providers: z.array(CustomProvider).default([]),
});
export type Config = z.infer<typeof ConfigSchema>;

Expand Down
13 changes: 7 additions & 6 deletions src/ui/App.commands.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ function makeProps(overrides: Partial<AppProps> = {}): AppProps {
agent,
bannerData,
parentSignal: new AbortController().signal,
readConfig: () => ({ backend: 'ollama', baseURL: '', apiKey: '', model: 'stub-model' }),
readConfig: () => ({ backend: 'ollama', baseURL: '', apiKey: '', model: 'stub-model', customProviders: [] }),
applyProvider,
setYolo,
...overrides,
Expand Down Expand Up @@ -189,7 +189,7 @@ describe('UI slash commands (terminal integration)', () => {

it('/provider can collect and test a Kimi API key before model selection', async () => {
mounted = renderApp({
readConfig: () => ({ backend: 'ollama', baseURL: '', apiKey: '', model: 'stub-model' }),
readConfig: () => ({ backend: 'ollama', baseURL: '', apiKey: '', model: 'stub-model', customProviders: [] }),
});
await tick();
await submit(mounted.stdin, '/provider');
Expand Down Expand Up @@ -228,6 +228,7 @@ describe('UI slash commands (terminal integration)', () => {
baseURL: 'https://api.groq.com/openai/v1',
apiKey: 'gsk-existing',
model: 'openai/gpt-oss-20b',
customProviders: [],
}),
});
await tick();
Expand All @@ -251,7 +252,7 @@ describe('UI slash commands (terminal integration)', () => {

it('/provider can collect and test a Groq API key before model selection', async () => {
mounted = renderApp({
readConfig: () => ({ backend: 'ollama', baseURL: '', apiKey: '', model: 'stub-model' }),
readConfig: () => ({ backend: 'ollama', baseURL: '', apiKey: '', model: 'stub-model', customProviders: [] }),
});
await tick();
await submit(mounted.stdin, '/provider');
Expand Down Expand Up @@ -295,7 +296,7 @@ describe('UI slash commands (terminal integration)', () => {
'models/gemini-flash-lite-latest',
]);
mounted = renderApp({
readConfig: () => ({ backend: 'ollama', baseURL: '', apiKey: '', model: 'stub-model' }),
readConfig: () => ({ backend: 'ollama', baseURL: '', apiKey: '', model: 'stub-model', customProviders: [] }),
});
await tick();
await submit(mounted.stdin, '/provider');
Expand Down Expand Up @@ -341,7 +342,7 @@ describe('UI slash commands (terminal integration)', () => {
it('/provider can collect and test an OpenRouter API key before model selection', async () => {
vi.mocked(listModels).mockResolvedValueOnce(['openrouter/auto', 'anthropic/claude-sonnet-4.5']);
mounted = renderApp({
readConfig: () => ({ backend: 'ollama', baseURL: '', apiKey: '', model: 'stub-model' }),
readConfig: () => ({ backend: 'ollama', baseURL: '', apiKey: '', model: 'stub-model', customProviders: [] }),
});
await tick();
await submit(mounted.stdin, '/provider');
Expand Down Expand Up @@ -390,7 +391,7 @@ describe('UI slash commands (terminal integration)', () => {
it('/provider can collect and test a DeepSeek API key before model selection', async () => {
vi.mocked(listModels).mockResolvedValueOnce(['deepseek-v4-flash', 'deepseek-v4-pro']);
mounted = renderApp({
readConfig: () => ({ backend: 'ollama', baseURL: '', apiKey: '', model: 'stub-model' }),
readConfig: () => ({ backend: 'ollama', baseURL: '', apiKey: '', model: 'stub-model', customProviders: [] }),
});
await tick();
await submit(mounted.stdin, '/provider');
Expand Down
Loading