feat: add support for custom provider configuration in the user config (#537)
### What - Add support for loading and merging custom provider configurations from a local `providers.json` file. - Allow users to override or extend default providers with their own settings. ### Why This change enables users to flexibly customize and extend provider endpoints and API keys without modifying the codebase, making the CLI more adaptable for various LLM backends and enterprise use cases. ### How - Introduced `loadProvidersFromFile` and `getMergedProviders` in config logic. - Added/updated related tests in [tests/config.test.tsx] ### Checklist - [x] Lint passes for changed files - [x] Tests pass for all files - [x] Documentation/comments updated as needed --------- Co-authored-by: Thibault Sottiaux <tibo@openai.com>
This commit is contained in:
@@ -572,6 +572,7 @@ export default function TerminalChat({
|
||||
{overlayMode === "model" && (
|
||||
<ModelOverlay
|
||||
currentModel={model}
|
||||
providers={config.providers}
|
||||
currentProvider={provider}
|
||||
hasLastResponse={Boolean(lastResponseId)}
|
||||
onSelect={(newModel) => {
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
getAvailableModels,
|
||||
RECOMMENDED_MODELS as _RECOMMENDED_MODELS,
|
||||
} from "../utils/model-utils.js";
|
||||
import { providers } from "../utils/providers.js";
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
@@ -19,6 +18,7 @@ type Props = {
|
||||
currentModel: string;
|
||||
currentProvider?: string;
|
||||
hasLastResponse: boolean;
|
||||
providers?: Record<string, { name: string; baseURL: string; envKey: string }>;
|
||||
onSelect: (model: string) => void;
|
||||
onSelectProvider?: (provider: string) => void;
|
||||
onExit: () => void;
|
||||
@@ -26,6 +26,7 @@ type Props = {
|
||||
|
||||
export default function ModelOverlay({
|
||||
currentModel,
|
||||
providers = {},
|
||||
currentProvider = "openai",
|
||||
hasLastResponse,
|
||||
onSelect,
|
||||
|
||||
@@ -42,33 +42,33 @@ export function setApiKey(apiKey: string): void {
|
||||
}
|
||||
|
||||
export function getBaseUrl(provider: string = "openai"): string | undefined {
|
||||
// If the provider is `openai` and `OPENAI_BASE_URL` is set, use it
|
||||
if (provider === "openai" && OPENAI_BASE_URL !== "") {
|
||||
return OPENAI_BASE_URL;
|
||||
}
|
||||
|
||||
// Check for a PROVIDER-specific override: e.g. OLLAMA_BASE_URL
|
||||
// Check for a PROVIDER-specific override: e.g. OPENAI_BASE_URL or OLLAMA_BASE_URL.
|
||||
const envKey = `${provider.toUpperCase()}_BASE_URL`;
|
||||
if (process.env[envKey]) {
|
||||
return process.env[envKey];
|
||||
}
|
||||
|
||||
// Use the default URL from providers if available
|
||||
const providerInfo = providers[provider.toLowerCase()];
|
||||
// Get providers config from config file.
|
||||
const config = loadConfig();
|
||||
const providersConfig = config.providers ?? providers;
|
||||
const providerInfo = providersConfig[provider.toLowerCase()];
|
||||
if (providerInfo) {
|
||||
return providerInfo.baseURL;
|
||||
}
|
||||
|
||||
// If the provider not found in the providers list and `OPENAI_BASE_URL` is set, use it
|
||||
// If the provider not found in the providers list and `OPENAI_BASE_URL` is set, use it.
|
||||
if (OPENAI_BASE_URL !== "") {
|
||||
return OPENAI_BASE_URL;
|
||||
}
|
||||
|
||||
// We tried.
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getApiKey(provider: string = "openai"): string | undefined {
|
||||
const providerInfo = providers[provider.toLowerCase()];
|
||||
const config = loadConfig();
|
||||
const providersConfig = config.providers ?? providers;
|
||||
const providerInfo = providersConfig[provider.toLowerCase()];
|
||||
if (providerInfo) {
|
||||
if (providerInfo.name === "Ollama") {
|
||||
return process.env[providerInfo.envKey] ?? "dummy";
|
||||
@@ -81,12 +81,10 @@ export function getApiKey(provider: string = "openai"): string | undefined {
|
||||
return OPENAI_API_KEY;
|
||||
}
|
||||
|
||||
// We tried.
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Formatting (quiet mode-only).
|
||||
export const PRETTY_PRINT = Boolean(process.env["PRETTY_PRINT"] || "");
|
||||
|
||||
// Represents config as persisted in config.json.
|
||||
export type StoredConfig = {
|
||||
model?: string;
|
||||
@@ -98,6 +96,7 @@ export type StoredConfig = {
|
||||
notify?: boolean;
|
||||
/** Disable server-side response storage (send full transcript each request) */
|
||||
disableResponseStorage?: boolean;
|
||||
providers?: Record<string, { name: string; baseURL: string; envKey: string }>;
|
||||
history?: {
|
||||
maxSize?: number;
|
||||
saveHistory?: boolean;
|
||||
@@ -134,6 +133,7 @@ export type AppConfig = {
|
||||
|
||||
/** Enable the "flex-mode" processing mode for supported models (o3, o4-mini) */
|
||||
flexMode?: boolean;
|
||||
providers?: Record<string, { name: string; baseURL: string; envKey: string }>;
|
||||
history?: {
|
||||
maxSize: number;
|
||||
saveHistory: boolean;
|
||||
@@ -141,6 +141,9 @@ export type AppConfig = {
|
||||
};
|
||||
};
|
||||
|
||||
// Formatting (quiet mode-only).
|
||||
export const PRETTY_PRINT = Boolean(process.env["PRETTY_PRINT"] || "");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project doc support (codex.md)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -399,6 +402,9 @@ export const loadConfig = (
|
||||
};
|
||||
}
|
||||
|
||||
// Merge default providers with user configured providers in the config.
|
||||
config.providers = { ...providers, ...storedConfig.providers };
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
@@ -431,6 +437,7 @@ export const saveConfig = (
|
||||
const configToSave: StoredConfig = {
|
||||
model: config.model,
|
||||
provider: config.provider,
|
||||
providers: config.providers,
|
||||
approvalMode: config.approvalMode,
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AutoApprovalMode } from "../src/utils/auto-approval-mode.js";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { test, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { providers as defaultProviders } from "../src/utils/providers";
|
||||
|
||||
// In‑memory FS store
|
||||
let memfs: Record<string, string> = {};
|
||||
@@ -148,3 +149,88 @@ test("loads and saves approvalMode correctly", () => {
|
||||
});
|
||||
expect(reloadedConfig.approvalMode).toBe(AutoApprovalMode.FULL_AUTO);
|
||||
});
|
||||
|
||||
test("loads and saves providers correctly", () => {
|
||||
// Setup custom providers configuration
|
||||
const customProviders = {
|
||||
openai: {
|
||||
name: "Custom OpenAI",
|
||||
baseURL: "https://custom-api.openai.com/v1",
|
||||
envKey: "CUSTOM_OPENAI_API_KEY",
|
||||
},
|
||||
anthropic: {
|
||||
name: "Anthropic",
|
||||
baseURL: "https://api.anthropic.com",
|
||||
envKey: "ANTHROPIC_API_KEY",
|
||||
},
|
||||
};
|
||||
|
||||
// Create config with providers
|
||||
const testConfig = {
|
||||
model: "test-model",
|
||||
provider: "anthropic",
|
||||
providers: customProviders,
|
||||
instructions: "test instructions",
|
||||
notify: false,
|
||||
};
|
||||
|
||||
// Save the config
|
||||
saveConfig(testConfig, testConfigPath, testInstructionsPath);
|
||||
|
||||
// Verify saved config contains providers
|
||||
expect(memfs[testConfigPath]).toContain(`"providers"`);
|
||||
expect(memfs[testConfigPath]).toContain(`"Custom OpenAI"`);
|
||||
expect(memfs[testConfigPath]).toContain(`"Anthropic"`);
|
||||
expect(memfs[testConfigPath]).toContain(`"provider": "anthropic"`);
|
||||
|
||||
// Load config and verify providers were loaded correctly
|
||||
const loadedConfig = loadConfig(testConfigPath, testInstructionsPath, {
|
||||
disableProjectDoc: true,
|
||||
});
|
||||
|
||||
// Check providers were loaded correctly
|
||||
expect(loadedConfig.provider).toBe("anthropic");
|
||||
expect(loadedConfig.providers).toEqual({
|
||||
...defaultProviders,
|
||||
...customProviders,
|
||||
});
|
||||
|
||||
// Test merging with built-in providers
|
||||
// Create a config with only one custom provider
|
||||
const partialProviders = {
|
||||
customProvider: {
|
||||
name: "Custom Provider",
|
||||
baseURL: "https://custom-api.example.com",
|
||||
envKey: "CUSTOM_API_KEY",
|
||||
},
|
||||
};
|
||||
|
||||
const partialConfig = {
|
||||
model: "test-model",
|
||||
provider: "customProvider",
|
||||
providers: partialProviders,
|
||||
instructions: "test instructions",
|
||||
notify: false,
|
||||
};
|
||||
|
||||
// Save the partial config
|
||||
saveConfig(partialConfig, testConfigPath, testInstructionsPath);
|
||||
|
||||
// Load config and verify providers were merged with built-in providers
|
||||
const mergedConfig = loadConfig(testConfigPath, testInstructionsPath, {
|
||||
disableProjectDoc: true,
|
||||
});
|
||||
|
||||
// Check providers is defined
|
||||
expect(mergedConfig.providers).toBeDefined();
|
||||
|
||||
// Use bracket notation to access properties
|
||||
if (mergedConfig.providers) {
|
||||
expect(mergedConfig.providers["customProvider"]).toBeDefined();
|
||||
expect(mergedConfig.providers["customProvider"]).toEqual(
|
||||
partialProviders.customProvider,
|
||||
);
|
||||
// Built-in providers should still be there (like openai)
|
||||
expect(mergedConfig.providers["openai"]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user