diff --git a/docs/config-reference.md b/docs/config-reference.md index 725d4e3..47c0901 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -104,6 +104,7 @@ Active LLM provider to use. | `"ollama"` | Local Ollama instance | | `"llamacpp"` | Local llama.cpp server | | `"openai"` | OpenAI API directly | +| `"openaicompatible"` | OpenAI-compatible API endpoint | | `"mlx"` | MLX on Apple Silicon (local) | | `"llmgateway"` | LLM Gateway unified API | | `"deepseek"` | DeepSeek API | @@ -213,6 +214,28 @@ OpenAI can also use your ChatGPT subscription via Autohand's built-in OpenAI sig | `contextWindow` | number | No | Auto | Exact model context window. Set this to override stale local assumptions. | | `chatgptAuth` | object | Yes for `chatgpt` mode | - | Stored ChatGPT/Codex auth tokens and account id | +### `openaicompatible` + +OpenAI-compatible provider configuration for custom gateways/proxies that expose an OpenAI-style API. + +```json +{ + "openaicompatible": { + "baseUrl": "https://your-provider.example.com/v1", + "model": "gpt-4o-mini", + "apiKey": "your-provider-key" + } +} +``` + +| Field | Type | Required | Default | Description | +| --------- | ------ | -------- | -------------------------- | ------------------------------------------------------------------ | +| `apiKey` | string | No | - | API key for endpoints that require bearer auth | +| `baseUrl` | string | Yes | - | OpenAI-compatible endpoint base URL (for example `.../v1`) | +| `model` | string | Yes | - | Model name supported by your provider | + +When `apiKey` is omitted, requests are sent without an `Authorization` header. + ### `mlx` MLX provider for Apple Silicon Macs (local inference). diff --git a/src/config.ts b/src/config.ts index bd78cda..bf03531 100644 --- a/src/config.ts +++ b/src/config.ts @@ -65,6 +65,7 @@ function normalizeProviderName(provider: unknown): ProviderName | undefined { "ollama", "llamacpp", "openai", + "openaicompatible", "mlx", "llmgateway", "azure", @@ -649,6 +650,7 @@ function isModernConfig( typeof (config as AutohandConfig).ollama === "object" || typeof (config as AutohandConfig).llamacpp === "object" || typeof (config as AutohandConfig).openai === "object" || + typeof (config as AutohandConfig).openaicompatible === "object" || typeof (config as AutohandConfig).mlx === "object" || typeof (config as AutohandConfig).azure === "object" || typeof (config as AutohandConfig).zai === "object" || @@ -832,6 +834,7 @@ export function getProviderConfig( ollama: config.ollama, llamacpp: config.llamacpp, openai: config.openai, + openaicompatible: config.openaicompatible, mlx: config.mlx, llmgateway: config.llmgateway, azure: config.azure, @@ -868,6 +871,20 @@ export function getProviderConfig( return null; } } + } else if (chosen === "openaicompatible") { + const { apiKey, model, baseUrl } = entry as ProviderSettings; + if (!model || !baseUrl) { + return null; + } + + const sanitizedApiKey = + apiKey && apiKey !== "replace-me" ? apiKey : undefined; + + return { + ...entry, + ...(sanitizedApiKey ? { apiKey: sanitizedApiKey } : {}), + baseUrl, + }; } else if ( chosen === "openrouter" || chosen === "llmgateway" || diff --git a/src/core/agent.ts b/src/core/agent.ts index ba633ec..03e7249 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -1585,6 +1585,7 @@ export class AutohandAgent { if (this.runtime.config.ollama) providers.push('ollama'); if (this.runtime.config.llamacpp) providers.push('llamacpp'); if (this.runtime.config.openai) providers.push('openai'); + if (this.runtime.config.openaicompatible) providers.push('openaicompatible'); if (this.runtime.config.mlx) providers.push('mlx'); if (this.runtime.config.llmgateway) providers.push('llmgateway'); if (this.runtime.config.zai) providers.push('zai'); diff --git a/src/core/agent/ProviderConfigManager.ts b/src/core/agent/ProviderConfigManager.ts index e6a1acf..7251f5e 100644 --- a/src/core/agent/ProviderConfigManager.ts +++ b/src/core/agent/ProviderConfigManager.ts @@ -62,6 +62,7 @@ import { authenticateOpenAIChatGPT } from "../../providers/openaiAuth.js"; type CloudProviderWithSettings = | "openai" + | "openaicompatible" | "openrouter" | "llmgateway" | "azure" @@ -85,6 +86,10 @@ type ProviderSettingsSummary = { }; export class ProviderConfigManager { + private readonly openAIApiKeyDefaultBaseUrl = "https://api.openai.com/v1"; + + private readonly openAIChatGPTDefaultBaseUrl = "https://chatgpt.com/backend-api/codex"; + constructor( private runtime: AgentRuntime, private getLlm: () => LLMProvider, @@ -376,6 +381,7 @@ export class ProviderConfigManager { ): provider is CloudProviderWithSettings { return [ "openai", + "openaicompatible", "openrouter", "llmgateway", "azure", @@ -390,6 +396,7 @@ export class ProviderConfigManager { return [ "openrouter", "openai", + "openaicompatible", "llmgateway", "azure", "zai", @@ -435,6 +442,15 @@ export class ProviderConfigManager { return !!openAIConfig.apiKey && openAIConfig.apiKey !== "replace-me"; } + if (provider === "openaicompatible") { + return ( + !!config.apiKey && + config.apiKey !== "replace-me" && + !!config.model && + !!config.baseUrl + ); + } + if ( provider === "openrouter" || provider === "llmgateway" || @@ -476,6 +492,9 @@ export class ProviderConfigManager { case "openai": await this.configureOpenAI(); break; + case "openaicompatible": + await this.configureOpenAICompatible(); + break; case "mlx": await this.configureMLX(); break; @@ -769,7 +788,9 @@ export class ProviderConfigManager { let apiKey = ""; let chatgptAuth; + let baseUrl = this.openAIApiKeyDefaultBaseUrl; if (authMode === "chatgpt") { + baseUrl = this.openAIChatGPTDefaultBaseUrl; try { console.log(chalk.gray(`\n${t("providers.openaiAuth.starting")}`)); chatgptAuth = await authenticateOpenAIChatGPT({ @@ -819,6 +840,14 @@ export class ProviderConfigManager { console.log(chalk.gray("\n" + t("providers.config.cancelled"))); return; } + + const enteredBaseUrl = await showInput({ + title: t("providers.config.changeBaseUrl"), + defaultValue: + this.runtime.config.openai?.baseUrl ?? + this.openAIApiKeyDefaultBaseUrl, + }); + baseUrl = enteredBaseUrl?.trim() || this.openAIApiKeyDefaultBaseUrl; } const modelChoices: ModalOption[] = OPENAI_MODELS.map((name) => ({ @@ -845,10 +874,7 @@ export class ProviderConfigManager { authMode, ...(authMode === "api-key" && { apiKey }), ...(authMode === "chatgpt" && { chatgptAuth }), - baseUrl: - authMode === "chatgpt" - ? "https://chatgpt.com/backend-api/codex" - : "https://api.openai.com/v1", + baseUrl, model, ...(reasoningEffort !== undefined && { reasoningEffort }), }; @@ -872,6 +898,94 @@ export class ProviderConfigManager { } } + /** + * Configure OpenAI-compatible provider (API key + base URL + model) + */ + private async configureOpenAICompatible(): Promise { + try { + console.log(chalk.cyan(t("providers.wizard.openaicompatible.title"))); + console.log( + chalk.gray( + t("providers.config.apiKeyUrl", { + url: t("providers.wizard.openaicompatible.apiKeyUrl"), + }) + "\n", + ), + ); + + const baseUrlInput = await showInput({ + title: t("providers.config.changeBaseUrl"), + defaultValue: + this.runtime.config.openaicompatible?.baseUrl ?? + this.openAIApiKeyDefaultBaseUrl, + }); + + const baseUrl = baseUrlInput?.trim().replace(/\/+$/, "") ?? ""; + if (!baseUrl) { + console.log(chalk.gray("\n" + t("providers.config.cancelled"))); + return; + } + + const apiKeyInput = await showPassword({ + title: t("providers.config.enterApiKeyOptional", { + provider: t("providers.openaicompatible"), + }), + placeholder: t("ui.apiKeyPlaceholder"), + validate: (val: string) => { + const trimmed = val?.trim(); + if (!trimmed) return true; + if (trimmed.length < 10) return t("providers.config.apiKeyTooShort"); + return true; + }, + }); + + if (apiKeyInput === undefined || apiKeyInput === null) { + console.log(chalk.gray("\n" + t("providers.config.cancelled"))); + return; + } + + const apiKey = apiKeyInput.trim(); + + const model = await showInput({ + title: t("providers.config.enterModelId"), + defaultValue: + this.runtime.config.openaicompatible?.model ?? "gpt-4o", + }); + + if (!model) { + console.log(chalk.gray("\n" + t("providers.config.cancelled"))); + return; + } + + const sanitizedModel = sanitizeModelId(model); + const contextWindow = await this.resolveContextWindow( + "openaicompatible", + sanitizedModel, + ); + this.runtime.config.openaicompatible = { + baseUrl, + model: sanitizedModel, + contextWindow, + ...(apiKey ? { apiKey } : {}), + }; + + this.runtime.config.provider = "openaicompatible"; + this.runtime.options.model = sanitizedModel; + await saveConfig(this.runtime.config); + this.resetLlmClient("openaicompatible", sanitizedModel); + + console.log( + chalk.green( + "\n✓ " + + t("providers.config.configuredSuccessfully", { + provider: t("providers.openaicompatible"), + }), + ), + ); + } catch (error) { + throw error; + } + } + /** * Configure MLX provider (Apple Silicon local inference) */ @@ -2163,6 +2277,7 @@ export class ProviderConfigManager { let newModel = currentModel; let newApiKey = currentSettings?.apiKey || ""; + let newBaseUrl = currentSettings?.baseUrl || ""; let authMode: OpenAIAuthMode | undefined = provider === "openai" ? this.runtime.config.openai?.authMode === "chatgpt" @@ -2232,6 +2347,7 @@ export class ProviderConfigManager { ) { const keyUrlMap = { openai: "https://platform.openai.com/api-keys", + openaicompatible: "https://platform.openai.com/api-keys", openrouter: "https://openrouter.ai/keys", llmgateway: "https://llmgateway.io/dashboard", azure: "https://ai.azure.com", @@ -2252,34 +2368,81 @@ export class ProviderConfigManager { title: t("providers.config.enterApiKey", { provider: providerName }), placeholder: t("ui.apiKeyPlaceholder"), validate: (val: string) => { - if (!val?.trim()) return t("providers.config.apiKeyRequired"); - if (val.length < 10) return t("providers.config.apiKeyTooShort"); + const trimmed = val?.trim(); + if (!trimmed) { + return provider === "openaicompatible" + ? true + : t("providers.config.apiKeyRequired"); + } + if (trimmed.length < 10) return t("providers.config.apiKeyTooShort"); return true; }, }); - if (!apiKey) { + if (apiKey === undefined || apiKey === null) { console.log( chalk.gray("\n" + t("providers.config.settingsChangeCancelled")), ); return; } - // Validate the API key - console.log(chalk.gray("\n" + t("providers.config.validatingApiKey"))); - const validationResult = await this.validateApiKey( - provider, - apiKey.trim(), - ); + const trimmedApiKey = apiKey.trim(); - if (!validationResult.valid) { - console.log(chalk.red(`\n✗ ${validationResult.error}`)); - console.log(chalk.gray(validationResult.hint || "")); - return; + if (provider === "openaicompatible") { + const baseUrlInput = await showInput({ + title: t("providers.config.changeBaseUrl"), + defaultValue: newBaseUrl || this.openAIApiKeyDefaultBaseUrl, + validate: (val: string) => { + const trimmed = val?.trim(); + if (!trimmed) return "Base URL is required"; + try { + const parsed = new URL(trimmed); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return "Base URL must start with http:// or https://"; + } + return true; + } catch { + return "Enter a valid URL"; + } + }, + }); + + if (!baseUrlInput) { + console.log( + chalk.gray("\n" + t("providers.config.settingsChangeCancelled")), + ); + return; + } + + newBaseUrl = baseUrlInput.trim().replace(/\/+$/, ""); } - console.log(chalk.green("✓ " + t("providers.config.apiKeyValid") + "\n")); - newApiKey = apiKey.trim(); + if (provider === "openaicompatible" && !trimmedApiKey) { + newApiKey = ""; + } else { + // Validate the API key + console.log(chalk.gray("\n" + t("providers.config.validatingApiKey"))); + const validationResult = await this.validateApiKey( + provider, + trimmedApiKey, + provider === "openaicompatible" + ? newBaseUrl + : provider === "openai" + ? (this.runtime.config.openai?.baseUrl ?? currentSettings?.baseUrl) + : undefined, + ); + + if (!validationResult.valid) { + console.log(chalk.red(`\n✗ ${validationResult.error}`)); + console.log(chalk.gray(validationResult.hint || "")); + return; + } + + console.log( + chalk.green("✓ " + t("providers.config.apiKeyValid") + "\n"), + ); + newApiKey = trimmedApiKey; + } } // Handle model change @@ -2483,11 +2646,25 @@ export class ProviderConfigManager { ...(newApiKey && { apiKey: newApiKey }), }; } else { + const existingOpenAIBaseUrl = + provider === "openai" + ? (this.runtime.config.openai?.baseUrl ?? currentSettings?.baseUrl) + : undefined; + const openAIDefaultBaseUrl = + authMode === "chatgpt" + ? this.openAIChatGPTDefaultBaseUrl + : this.openAIApiKeyDefaultBaseUrl; + const shouldPreserveCustomOpenAIBaseUrl = + provider === "openai" && + authMode !== "chatgpt" && + !!existingOpenAIBaseUrl && + existingOpenAIBaseUrl !== this.openAIApiKeyDefaultBaseUrl && + existingOpenAIBaseUrl !== this.openAIChatGPTDefaultBaseUrl; const baseUrlMap = { - openai: - authMode === "chatgpt" - ? "https://chatgpt.com/backend-api/codex" - : "https://api.openai.com/v1", + openai: shouldPreserveCustomOpenAIBaseUrl + ? existingOpenAIBaseUrl + : openAIDefaultBaseUrl, + openaicompatible: newBaseUrl, openrouter: "https://openrouter.ai/api/v1", llmgateway: "https://api.llmgateway.io/v1", zai: ZAI_DEFAULT_BASE_URL, @@ -2506,6 +2683,13 @@ export class ProviderConfigManager { contextWindow, ...(reasoningEffort !== undefined && { reasoningEffort }), }; + } else if (provider === "openaicompatible") { + this.runtime.config.openaicompatible = { + baseUrl, + model: newModel, + contextWindow, + ...(newApiKey ? { apiKey: newApiKey } : {}), + }; } else if (provider === "openrouter") { this.runtime.config.openrouter = { apiKey: newApiKey, @@ -2645,8 +2829,9 @@ export class ProviderConfigManager { * Validate API key by making a test request to the provider */ private async validateApiKey( - provider: "openai" | "openrouter" | "llmgateway" | "azure" | "zai" | "xai" | "cerebras" | "nvidia" | "deepseek", + provider: "openai" | "openaicompatible" | "openrouter" | "llmgateway" | "azure" | "zai" | "xai" | "cerebras" | "nvidia" | "deepseek", apiKey: string, + baseUrlOverride?: string, ): Promise<{ valid: boolean; error?: string; hint?: string }> { // Azure keys can't be easily validated without resource/deployment info if (provider === "azure") { @@ -2656,6 +2841,7 @@ export class ProviderConfigManager { try { const baseUrlMap = { openai: "https://api.openai.com/v1", + openaicompatible: baseUrlOverride || this.openAIApiKeyDefaultBaseUrl, openrouter: "https://openrouter.ai/api/v1", llmgateway: "https://api.llmgateway.io/v1", zai: ZAI_DEFAULT_BASE_URL, @@ -2699,6 +2885,7 @@ export class ProviderConfigManager { const keyUrlMap = { openai: "https://platform.openai.com/api-keys", + openaicompatible: "https://platform.openai.com/api-keys", openrouter: "https://openrouter.ai/keys", llmgateway: "https://llmgateway.io/dashboard", zai: "https://z.ai/api-keys", @@ -2822,6 +3009,13 @@ export class ProviderConfigManager { apiKey: "", model, }), + openaicompatible: + this.runtime.config.openaicompatible ?? + (this.runtime.config.openaicompatible = { + apiKey: "", + baseUrl: this.openAIApiKeyDefaultBaseUrl, + model, + }), mlx: this.runtime.config.mlx ?? (this.runtime.config.mlx = { model }), llmgateway: this.runtime.config.llmgateway ?? diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f0be29e..129b114 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -681,6 +681,7 @@ "providers": { "openrouter": "OpenRouter", "openai": "OpenAI", + "openaicompatible": "OpenAI Compatible", "ollama": "Ollama", "llamacpp": "llama.cpp", "mlx": "MLX (Apple Silicon)", @@ -713,6 +714,7 @@ "hints": { "openrouter": "Cloud - Access to 100+ models (Claude, GPT-4, etc.)", "openai": "Cloud - Official OpenAI models (GPT-4o, o1, etc.)", + "openaicompatible": "Cloud - OpenAI-compatible APIs (custom endpoint)", "ollama": "Local - Run models on your machine (free)", "llamacpp": "Local - Fast inference with GGUF models", "mlx": "Local - Optimized for Apple Silicon Macs", @@ -737,6 +739,7 @@ "reasoningEffortLabel": "Reasoning effort: {{level}}", "enterModelId": "Enter the model ID", "enterApiKey": "Enter your {{provider}} API key", + "enterApiKeyOptional": "Enter your {{provider}} API key (leave blank if not required)", "apiKeyUrl": "Get your API key at: {{url}}", "modelChangeCancelled": "Model change cancelled.", "modelUnchanged": "Model unchanged.", @@ -789,6 +792,10 @@ "title": "OpenAI Configuration", "apiKeyUrl": "https://platform.openai.com/api-keys" }, + "openaicompatible": { + "title": "OpenAI-Compatible Configuration", + "apiKeyUrl": "https://platform.openai.com/api-keys" + }, "ollama": { "title": "Ollama Configuration", "ensureRunning": "Make sure Ollama is running: ollama serve", diff --git a/src/onboarding/setupWizard.ts b/src/onboarding/setupWizard.ts index 4e0adac..1c7d6e4 100644 --- a/src/onboarding/setupWizard.ts +++ b/src/onboarding/setupWizard.ts @@ -210,6 +210,20 @@ export class SetupWizard { } else { const apiKey = await this.promptApiKey(provider); if (apiKey === null) return this.cancelled(); + + const baseUrlConfigured = await this.promptOpenAIApiKeyBaseUrl(); + if (!baseUrlConfigured) return this.cancelled(); + + await this.validateApiKeyDuringSetup(); + } + } else if (provider === 'openaicompatible') { + const baseUrlConfigured = await this.promptOpenAICompatibleBaseUrl(); + if (!baseUrlConfigured) return this.cancelled(); + + const apiKey = await this.promptApiKey(provider, { required: false }); + if (apiKey === null) return this.cancelled(); + + if (apiKey) { await this.validateApiKeyDuringSetup(); } } else if (this.requiresApiKey(provider)) { @@ -432,8 +446,12 @@ export class SetupWizard { /** * Prompt for API key (cloud providers) */ - private async promptApiKey(provider: ProviderName): Promise { + private async promptApiKey( + provider: ProviderName, + options?: { required?: boolean }, + ): Promise { this.state.currentStep = 'apiKey'; + const required = options?.required !== false; // Check for existing key const existingKey = this.getExistingApiKey(provider); @@ -456,18 +474,22 @@ export class SetupWizard { title: t('providers.config.enterApiKey', { provider: this.getProviderDisplayName(provider) }), placeholder: t('ui.apiKeyPlaceholder'), validate: (val: string) => { - if (!val?.trim()) return t('providers.config.apiKeyRequired'); - if (val.length < 10) return t('providers.config.apiKeyTooShort'); + const trimmed = val?.trim(); + if (!trimmed) { + return required ? t('providers.config.apiKeyRequired') : true; + } + if (trimmed.length < 10) return t('providers.config.apiKeyTooShort'); return true; } }); - if (!apiKey) { + if (apiKey === undefined || apiKey === null) { return null; } - this.state.apiKey = apiKey.trim(); - return this.state.apiKey; + const trimmedApiKey = apiKey.trim(); + this.state.apiKey = trimmedApiKey || undefined; + return trimmedApiKey; } private async promptOpenAIAuthMode(): Promise { @@ -517,6 +539,78 @@ export class SetupWizard { } } + private async promptOpenAIApiKeyBaseUrl(): Promise { + const defaultBaseUrl = this.getDefaultBaseUrl('openai'); + const existingBaseUrl = this.existingConfig?.openai?.authMode === 'api-key' + ? this.existingConfig.openai.baseUrl + : undefined; + + const useCustomBaseUrl = await showConfirm({ + title: 'Use a custom OpenAI-compatible base URL?', + defaultValue: false + }); + + if (!useCustomBaseUrl) { + this.state.providerBaseUrl = undefined; + return true; + } + + const baseUrl = await showInput({ + title: 'Enter OpenAI-compatible base URL', + defaultValue: existingBaseUrl ?? defaultBaseUrl, + validate: (val: string) => { + const trimmed = val?.trim(); + if (!trimmed) return 'Base URL is required'; + try { + const parsed = new URL(trimmed); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return 'Base URL must start with http:// or https://'; + } + return true; + } catch { + return 'Enter a valid URL'; + } + } + }); + + if (!baseUrl) { + return false; + } + + this.state.providerBaseUrl = baseUrl.trim().replace(/\/+$/, ''); + return true; + } + + private async promptOpenAICompatibleBaseUrl(): Promise { + const defaultBaseUrl = this.getDefaultBaseUrl('openaicompatible'); + const existingBaseUrl = this.existingConfig?.openaicompatible?.baseUrl; + + const baseUrl = await showInput({ + title: 'Enter OpenAI-compatible base URL', + defaultValue: existingBaseUrl ?? defaultBaseUrl, + validate: (val: string) => { + const trimmed = val?.trim(); + if (!trimmed) return 'Base URL is required'; + try { + const parsed = new URL(trimmed); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return 'Base URL must start with http:// or https://'; + } + return true; + } catch { + return 'Enter a valid URL'; + } + } + }); + + if (!baseUrl) { + return false; + } + + this.state.providerBaseUrl = baseUrl.trim().replace(/\/+$/, ''); + return true; + } + /** * Prompt for model selection */ @@ -1045,9 +1139,15 @@ export class SetupWizard { authMode: 'api-key', apiKey: this.state.apiKey, model: this.state.model ?? this.getDefaultModel('openai'), - baseUrl: this.getDefaultBaseUrl('openai'), + baseUrl: this.state.providerBaseUrl ?? this.getDefaultBaseUrl('openai'), ...(this.state.reasoningEffort !== undefined && { reasoningEffort: this.state.reasoningEffort }) }; + } else if (this.state.provider === 'openaicompatible') { + config.openaicompatible = { + model: this.state.model ?? this.getDefaultModel('openaicompatible'), + baseUrl: this.state.providerBaseUrl ?? this.getDefaultBaseUrl('openaicompatible'), + ...(this.state.apiKey ? { apiKey: this.state.apiKey } : {}), + }; } else if (this.state.provider === 'vertexai' && this.state.vertexaiConfig) { config.vertexai = this.state.vertexaiConfig; } else if (this.state.provider === 'bedrock' && this.state.bedrockConfig) { @@ -1593,7 +1693,7 @@ export class SetupWizard { if (!this.state.provider || !this.state.apiKey) return; if (!this.requiresApiKey(this.state.provider)) return; - const baseUrl = this.getDefaultBaseUrl(this.state.provider); + const baseUrl = this.state.providerBaseUrl ?? this.getDefaultBaseUrl(this.state.provider); console.log(chalk.gray('\n ' + t('setup.apiKeyValidation.validating'))); try { @@ -2039,6 +2139,7 @@ export class SetupWizard { const urls: Record = { openrouter: t('providers.wizard.openrouter.apiKeyUrl'), openai: t('providers.wizard.openai.apiKeyUrl'), + openaicompatible: t('providers.wizard.openaicompatible.apiKeyUrl'), llmgateway: t('providers.wizard.llmgateway.apiKeyUrl'), zai: t('providers.wizard.zai.apiKeyUrl'), nvidia: t('providers.wizard.nvidia.apiKeyUrl'), @@ -2052,6 +2153,7 @@ export class SetupWizard { const defaults: Record = { openrouter: 'nvidia/nemotron-3-super-120b-a12b:free', openai: 'gpt-5.4', + openaicompatible: 'gpt-4o-mini', ollama: 'llama3.2:latest', llamacpp: 'local', mlx: 'mlx-community/Llama-3.2-3B-Instruct-4bit', @@ -2072,6 +2174,7 @@ export class SetupWizard { const urls: Record = { openrouter: 'https://openrouter.ai/api/v1', openai: 'https://api.openai.com/v1', + openaicompatible: 'https://api.openai.com/v1', ollama: 'http://localhost:11434', llamacpp: 'http://localhost:8080', mlx: 'http://localhost:8080', diff --git a/src/providers/OpenAIProvider.ts b/src/providers/OpenAIProvider.ts index 33a62e0..946d739 100644 --- a/src/providers/OpenAIProvider.ts +++ b/src/providers/OpenAIProvider.ts @@ -310,8 +310,18 @@ export class OpenAIProvider implements LLMProvider { } const data = await response.json() as OpenAIChatResponse; - const message = data.choices[0].message; - const finishReason = data.choices[0].finish_reason; + const firstChoice = data.choices?.[0]; + if (!firstChoice?.message) { + throw new ApiError( + 'Received an invalid response from the OpenAI-compatible endpoint. Expected choices[0].message.', + 'unknown', + response.status, + false, + ); + } + + const message = firstChoice.message; + const finishReason = firstChoice.finish_reason; // Parse tool calls if present let toolCalls: LLMToolCall[] | undefined; @@ -647,6 +657,10 @@ export class OpenAIProvider implements LLMProvider { }; } + if (!this.apiKey.trim()) { + return {}; + } + return { Authorization: `Bearer ${this.apiKey}`, }; diff --git a/src/providers/ProviderFactory.ts b/src/providers/ProviderFactory.ts index 06154a5..2e1d63b 100644 --- a/src/providers/ProviderFactory.ts +++ b/src/providers/ProviderFactory.ts @@ -88,6 +88,12 @@ export class ProviderFactory { } return new OpenAIProvider(config.openai); + case 'openaicompatible': + if (!config.openaicompatible) { + return new UnconfiguredProvider('openaicompatible'); + } + return new OpenAIProvider(config.openaicompatible); + case 'llamacpp': if (!config.llamacpp) { return new UnconfiguredProvider('llamacpp'); @@ -168,8 +174,8 @@ export class ProviderFactory { * MLX is only included on Apple Silicon (macOS + arm64). */ static getProviderNames(config?: Pick | null): ProviderName[] { - // Sorted DESC by display name: Z.ai, xAI, Vertex AI, NVIDIA, OpenRouter, OpenAI, Ollama, MLX, LLM Gateway, llama.cpp, DeepSeek, Cerebras, Bedrock, Azure - const providers: ProviderName[] = ['zai', 'xai', 'vertexai', 'nvidia', 'openrouter', 'openai', 'ollama', 'llmgateway', 'llamacpp', 'deepseek', 'cerebras', 'azure']; + // Sorted DESC by display name: Z.ai, xAI, Vertex AI, NVIDIA, OpenRouter, OpenAI, OpenAI Compatible, Ollama, MLX, LLM Gateway, llama.cpp, DeepSeek, Cerebras, Bedrock, Azure + const providers: ProviderName[] = ['zai', 'xai', 'vertexai', 'nvidia', 'openrouter', 'openai', 'openaicompatible', 'ollama', 'llmgateway', 'llamacpp', 'deepseek', 'cerebras', 'azure']; if (isAwsBedrockProviderEnabled(config)) { providers.splice(providers.indexOf('azure'), 0, 'bedrock'); } @@ -189,7 +195,7 @@ export class ProviderFactory { return false; } - const allProviders: ProviderName[] = ['openrouter', 'ollama', 'openai', 'llamacpp', 'mlx', 'llmgateway', 'azure', 'zai', 'vertexai', 'xai', 'cerebras', 'nvidia', 'deepseek', 'bedrock']; + const allProviders: ProviderName[] = ['openrouter', 'ollama', 'openai', 'openaicompatible', 'llamacpp', 'mlx', 'llmgateway', 'azure', 'zai', 'vertexai', 'xai', 'cerebras', 'nvidia', 'deepseek', 'bedrock']; return allProviders.includes(name as ProviderName); } } diff --git a/src/types.ts b/src/types.ts index f1b5207..8432513 100644 --- a/src/types.ts +++ b/src/types.ts @@ -32,7 +32,7 @@ type Primitive = string | number | boolean | null; export type MessageRole = 'system' | 'user' | 'assistant' | 'tool'; -export type ProviderName = 'openrouter' | 'ollama' | 'llamacpp' | 'openai' | 'mlx' | 'llmgateway' | 'azure' | 'zai' | 'vertexai' | 'xai' | 'cerebras' | 'nvidia' | 'deepseek' | 'bedrock'; +export type ProviderName = 'openrouter' | 'ollama' | 'llamacpp' | 'openai' | 'openaicompatible' | 'mlx' | 'llmgateway' | 'azure' | 'zai' | 'vertexai' | 'xai' | 'cerebras' | 'nvidia' | 'deepseek' | 'bedrock'; export type AzureAuthMethod = 'api-key' | 'entra-id' | 'managed-identity'; export type OpenAIAuthMode = 'api-key' | 'chatgpt'; @@ -74,6 +74,11 @@ export interface OpenAISettings extends ProviderSettings { chatgptAuth?: OpenAIChatGPTAuth; } +export interface OpenAICompatibleSettings extends ProviderSettings { + apiKey?: string; + baseUrl: string; +} + export interface AzureSettings extends ProviderSettings { /** Azure resource name (e.g., "my-openai-resource") */ resourceName?: string; @@ -653,6 +658,7 @@ export interface AutohandConfig { ollama?: ProviderSettings; llamacpp?: ProviderSettings; openai?: OpenAISettings; + openaicompatible?: OpenAICompatibleSettings; mlx?: ProviderSettings; llmgateway?: LLMGatewaySettings; /** Azure OpenAI settings */ diff --git a/tests/configProviders.spec.ts b/tests/configProviders.spec.ts index d904e40..a761753 100644 --- a/tests/configProviders.spec.ts +++ b/tests/configProviders.spec.ts @@ -116,6 +116,52 @@ describe('getProviderConfig', () => { expect(result).toBeNull(); }); + it('returns openaicompatible settings when model and base url are configured', () => { + const cfg = { + provider: 'openaicompatible', + openaicompatible: { + apiKey: 'compat-key', + model: 'gpt-4o-mini', + baseUrl: 'https://proxy.example.com/v1' + } + } as unknown as AutohandConfig; + + const result = getProviderConfig(cfg, 'openaicompatible' as any); + expect(result).not.toBeNull(); + expect(result!.apiKey).toBe('compat-key'); + expect(result!.model).toBe('gpt-4o-mini'); + expect(result!.baseUrl).toBe('https://proxy.example.com/v1'); + }); + + it('returns openaicompatible settings when api key is omitted', () => { + const cfg = { + provider: 'openaicompatible', + openaicompatible: { + model: 'gpt-4o-mini', + baseUrl: 'https://proxy.example.com/v1' + } + } as unknown as AutohandConfig; + + const result = getProviderConfig(cfg, 'openaicompatible' as any); + expect(result).not.toBeNull(); + expect(result!.apiKey).toBeUndefined(); + expect(result!.model).toBe('gpt-4o-mini'); + expect(result!.baseUrl).toBe('https://proxy.example.com/v1'); + }); + + it('returns null for openaicompatible when base url is missing', () => { + const cfg = { + provider: 'openaicompatible', + openaicompatible: { + apiKey: 'compat-key', + model: 'gpt-4o-mini' + } + } as unknown as AutohandConfig; + + const result = getProviderConfig(cfg, 'openaicompatible' as any); + expect(result).toBeNull(); + }); + it('returns nvidia settings when configured', () => { const cfg: AutohandConfig = { provider: 'nvidia', diff --git a/tests/core/agent/ProviderConfigManager.openai.test.ts b/tests/core/agent/ProviderConfigManager.openai.test.ts index 9ca0e58..e8343b4 100644 --- a/tests/core/agent/ProviderConfigManager.openai.test.ts +++ b/tests/core/agent/ProviderConfigManager.openai.test.ts @@ -142,6 +142,92 @@ describe("ProviderConfigManager openai auth mode", () => { expect(mockSaveConfig).toHaveBeenCalledOnce(); }); + it("allows configuring openai with a custom compatible base URL", async () => { + const customBaseUrl = "https://openai-proxy.example.com/v1"; + + mockShowModal + .mockResolvedValueOnce({ value: "api-key" }) + .mockResolvedValueOnce({ value: "gpt-5.4" }) + .mockResolvedValueOnce({ value: "high" }); + mockShowPassword.mockResolvedValueOnce("sk-openai-key-1234567890"); + mockShowInput.mockResolvedValueOnce(customBaseUrl); + + await (manager as any).configureOpenAI(); + + expect(mockShowInput).toHaveBeenCalledOnce(); + expect(runtime.config.openai).toMatchObject({ + authMode: "api-key", + apiKey: "sk-openai-key-1234567890", + model: "gpt-5.4", + baseUrl: customBaseUrl, + }); + expect(mockSaveConfig).toHaveBeenCalledOnce(); + }); + + it("configures openaicompatible with api-key auth semantics and custom base URL", async () => { + mockShowPassword.mockResolvedValueOnce("sk-openai-compat-1234567890"); + mockShowInput + .mockResolvedValueOnce("https://proxy.example.com/v1") + .mockResolvedValueOnce("gpt-4o-mini"); + + await (manager as any).configureOpenAICompatible(); + + expect(runtime.config.provider).toBe("openaicompatible"); + expect(runtime.config.openaicompatible).toMatchObject({ + apiKey: "sk-openai-compat-1234567890", + baseUrl: "https://proxy.example.com/v1", + model: "gpt-4o-mini", + }); + expect(mockSaveConfig).toHaveBeenCalledOnce(); + }); + + it("configures openaicompatible with endpoint and model when API key is omitted", async () => { + mockShowPassword.mockResolvedValueOnce(""); + mockShowInput + .mockResolvedValueOnce("https://proxy.example.com/v1") + .mockResolvedValueOnce("gpt-4o-mini"); + + await (manager as any).configureOpenAICompatible(); + + expect(runtime.config.provider).toBe("openaicompatible"); + expect(runtime.config.openaicompatible).toMatchObject({ + baseUrl: "https://proxy.example.com/v1", + model: "gpt-4o-mini", + }); + expect(runtime.config.openaicompatible?.apiKey).toBeUndefined(); + expect(mockSaveConfig).toHaveBeenCalledOnce(); + }); + + it("validates openaicompatible api key against the newly entered base URL", async () => { + runtime.config.provider = "openaicompatible"; + runtime.config.openaicompatible = { + apiKey: "sk-openai-compat-old-1234567890", + baseUrl: "https://old-proxy.example.com/v1", + model: "gpt-4o-mini", + }; + runtime.options.model = "gpt-4o-mini"; + + mockShowModal.mockResolvedValueOnce({ value: "apiKey" }); + mockShowPassword.mockResolvedValueOnce("sk-openai-compat-new-1234567890"); + mockShowInput.mockResolvedValueOnce("https://new-proxy.example.com/v1"); + + const validateApiKeySpy = vi + .spyOn(manager as any, "validateApiKey") + .mockResolvedValue({ valid: true }); + + await manager.promptModelSelection(); + + expect(validateApiKeySpy).toHaveBeenCalledWith( + "openaicompatible", + "sk-openai-compat-new-1234567890", + "https://new-proxy.example.com/v1", + ); + expect(runtime.config.openaicompatible.baseUrl).toBe( + "https://new-proxy.example.com/v1", + ); + expect(mockSaveConfig).toHaveBeenCalledOnce(); + }); + it("prints a visible sign-in status before starting chatgpt auth", async () => { mockAuthenticateOpenAIChatGPT.mockResolvedValue({ accessToken: "chatgpt-access-token", @@ -334,6 +420,87 @@ describe("ProviderConfigManager openai auth mode", () => { expect(mockShowModal.mock.calls[1][0].initialIndex).toBe(3); }); + it.each(["model", "auth", "reasoning"] as const)( + "keeps a custom OpenAI baseUrl when changing %s settings", + async (settingsAction) => { + const customBaseUrl = "https://openai-proxy.example.com/v1"; + let validateApiKeySpy: ReturnType | undefined; + runtime.config.provider = "openai"; + runtime.config.openai = { + authMode: "api-key", + apiKey: "sk-openai-key-1234567890", + model: "gpt-5.4", + baseUrl: customBaseUrl, + reasoningEffort: "high", + }; + runtime.options.model = "gpt-5.4"; + + if (settingsAction === "model") { + mockShowModal + .mockResolvedValueOnce({ value: "model" }) + .mockResolvedValueOnce({ value: "gpt-5.4" }) + .mockResolvedValueOnce({ value: "xhigh" }); + } + + if (settingsAction === "auth") { + mockShowModal + .mockResolvedValueOnce({ value: "auth" }) + .mockResolvedValueOnce({ value: "api-key" }); + mockShowPassword.mockResolvedValueOnce("sk-openai-key-0987654321"); + validateApiKeySpy = vi.spyOn(manager as any, "validateApiKey").mockResolvedValue({ + valid: true, + }); + } + + if (settingsAction === "reasoning") { + mockShowModal + .mockResolvedValueOnce({ value: "reasoning" }) + .mockResolvedValueOnce({ value: "xhigh" }); + } + + await manager.promptModelSelection(); + + if (settingsAction === "auth") { + expect(validateApiKeySpy).toHaveBeenCalledWith( + "openai", + "sk-openai-key-0987654321", + customBaseUrl, + ); + } + + expect(runtime.config.openai.baseUrl).toBe(customBaseUrl); + expect(mockSaveConfig).toHaveBeenCalledOnce(); + }, + ); + + it("switches OpenAI auth to chatgpt and resets baseUrl to codex endpoint", async () => { + const customBaseUrl = "https://openai-proxy.example.com/v1"; + runtime.config.provider = "openai"; + runtime.config.openai = { + authMode: "api-key", + apiKey: "sk-openai-key-1234567890", + model: "gpt-5.4", + baseUrl: customBaseUrl, + reasoningEffort: "high", + }; + runtime.options.model = "gpt-5.4"; + + mockAuthenticateOpenAIChatGPT.mockResolvedValueOnce({ + accessToken: "chatgpt-access-token", + accountId: "chatgpt-account-123", + }); + + mockShowModal + .mockResolvedValueOnce({ value: "auth" }) + .mockResolvedValueOnce({ value: "chatgpt" }); + + await manager.promptModelSelection(); + + expect(runtime.config.openai.authMode).toBe("chatgpt"); + expect(runtime.config.openai.baseUrl).toBe("https://chatgpt.com/backend-api/codex"); + expect(mockSaveConfig).toHaveBeenCalledOnce(); + }); + it("shows user-facing provider names in provider selection when no active provider is configured", async () => { runtime.config.provider = "zai"; diff --git a/tests/onboarding/setupWizard.test.ts b/tests/onboarding/setupWizard.test.ts index 9319132..a27fa20 100644 --- a/tests/onboarding/setupWizard.test.ts +++ b/tests/onboarding/setupWizard.test.ts @@ -7,7 +7,14 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import type { LoadedConfig } from "../../src/types"; -// Use vi.hoisted() to ensure mock functions are available when vi.mock is hoisted +const viWithOptionalHoisted = vi as typeof vi & { + hoisted?: (factory: () => T) => T; +}; + +if (typeof viWithOptionalHoisted.hoisted !== "function") { + viWithOptionalHoisted.hoisted = (factory: () => T): T => factory(); +} + const { mockShowModal, mockShowInput, @@ -167,6 +174,36 @@ function setupCloudProviderMocks( apiKey: string, model: string, ) { + if (provider === "openai") { + // showModal calls: language, provider, auth mode, reasoning effort, permissions + mockShowModal + .mockResolvedValueOnce({ value: "en" }) // language + .mockResolvedValueOnce({ value: provider }) // provider + .mockResolvedValueOnce({ value: "api-key" }) // auth mode + .mockResolvedValueOnce({ value: "high" }) // reasoning effort + .mockResolvedValueOnce({ value: "interactive" }); // permissions + + // showPassword: API key + mockShowPassword.mockResolvedValueOnce(apiKey); + + // showInput: model + mockShowInput.mockResolvedValueOnce(model); + + // showConfirm calls: custom base URL, remember, telemetry, autoReport, prefs, advanced, agents, registration, review + mockShowConfirm + .mockResolvedValueOnce(false) // keep default OpenAI base URL + .mockResolvedValueOnce(true) // remember session + .mockResolvedValueOnce(true) // telemetry + .mockResolvedValueOnce(true) // autoReport + .mockResolvedValueOnce(false) // preferences (skip) + .mockResolvedValueOnce(false) // advanced (skip) + .mockResolvedValueOnce(false) // agents (skip) + .mockResolvedValueOnce(false) // registration (skip) + .mockResolvedValueOnce(true); // review confirm + + return; + } + // showModal calls: language, provider, permissions mockShowModal .mockResolvedValueOnce({ value: "en" }) // language @@ -250,7 +287,11 @@ describe("SetupWizard", () => { mockChangeLanguage.mockResolvedValue(undefined); // Default: fetch succeeds (for API validation + connection tests) mockFetch.mockResolvedValue({ ok: true, status: 200 }); - vi.stubGlobal("fetch", mockFetch); + if (typeof vi.stubGlobal === "function") { + vi.stubGlobal("fetch", mockFetch); + } else { + globalThis.fetch = mockFetch as unknown as typeof fetch; + } mockProbeLlamaCppEnvironment.mockResolvedValue({ installed: true, running: false, @@ -908,6 +949,109 @@ describe("SetupWizard", () => { expect(result.config.openai?.baseUrl).toBe("https://api.openai.com/v1"); }); + it("should persist a custom OpenAI-compatible base URL for OpenAI api-key auth", async () => { + const wizard = new SetupWizard(testWorkspace); + + mockShowModal + .mockResolvedValueOnce({ value: "en" }) // language + .mockResolvedValueOnce({ value: "openai" }) // provider + .mockResolvedValueOnce({ value: "api-key" }) // auth mode + .mockResolvedValueOnce({ value: "high" }) // reasoning effort + .mockResolvedValueOnce({ value: "interactive" }); // permissions + + mockShowPassword.mockResolvedValueOnce("sk-openai-test-key-long"); + + mockShowInput + .mockResolvedValueOnce("https://openai-gateway.example.com/v1") // custom base URL + .mockResolvedValueOnce("gpt-5.4"); // model + + mockShowConfirm + .mockResolvedValueOnce(true) // customize OpenAI base URL + .mockResolvedValueOnce(true) // remember + .mockResolvedValueOnce(true) // telemetry + .mockResolvedValueOnce(true) // autoReport + .mockResolvedValueOnce(false) // prefs + .mockResolvedValueOnce(false) // advanced + .mockResolvedValueOnce(false) // agents + .mockResolvedValueOnce(false) // registration + .mockResolvedValueOnce(true); // review + + const result = await wizard.run({ skipWelcome: true }); + + expect(result.success).toBe(true); + expect(result.config.openai?.baseUrl).toBe( + "https://openai-gateway.example.com/v1", + ); + }); + + it("should persist api key, model, and base URL for openaicompatible", async () => { + const wizard = new SetupWizard(testWorkspace); + + mockShowModal + .mockResolvedValueOnce({ value: "en" }) // language + .mockResolvedValueOnce({ value: "openaicompatible" }) // provider + .mockResolvedValueOnce({ value: "interactive" }); // permissions + + mockShowPassword.mockResolvedValueOnce("sk-openai-compat-key-long"); + mockShowInput + .mockResolvedValueOnce("https://openai-proxy.example.com/v1") // base URL + .mockResolvedValueOnce("gpt-4o-mini"); // model + + mockShowConfirm + .mockResolvedValueOnce(true) // remember + .mockResolvedValueOnce(true) // telemetry + .mockResolvedValueOnce(true) // autoReport + .mockResolvedValueOnce(false) // prefs + .mockResolvedValueOnce(false) // advanced + .mockResolvedValueOnce(false) // agents + .mockResolvedValueOnce(false) // registration + .mockResolvedValueOnce(true); // review + + const result = await wizard.run({ skipWelcome: true }); + + expect(result.success).toBe(true); + expect(result.config.provider).toBe("openaicompatible"); + expect((result.config as any).openaicompatible).toMatchObject({ + apiKey: "sk-openai-compat-key-long", + model: "gpt-4o-mini", + baseUrl: "https://openai-proxy.example.com/v1", + }); + }); + + it("should allow openaicompatible setup with endpoint and model when API key is not required", async () => { + const wizard = new SetupWizard(testWorkspace); + + mockShowModal + .mockResolvedValueOnce({ value: "en" }) // language + .mockResolvedValueOnce({ value: "openaicompatible" }) // provider + .mockResolvedValueOnce({ value: "interactive" }); // permissions + + mockShowPassword.mockResolvedValueOnce(""); + mockShowInput + .mockResolvedValueOnce("https://openai-proxy.example.com/v1") // base URL + .mockResolvedValueOnce("gpt-4o-mini"); // model + + mockShowConfirm + .mockResolvedValueOnce(true) // remember + .mockResolvedValueOnce(true) // telemetry + .mockResolvedValueOnce(true) // autoReport + .mockResolvedValueOnce(false) // prefs + .mockResolvedValueOnce(false) // advanced + .mockResolvedValueOnce(false) // agents + .mockResolvedValueOnce(false) // registration + .mockResolvedValueOnce(true); // review + + const result = await wizard.run({ skipWelcome: true }); + + expect(result.success).toBe(true); + expect(result.config.provider).toBe("openaicompatible"); + expect((result.config as any).openaicompatible).toMatchObject({ + model: "gpt-4o-mini", + baseUrl: "https://openai-proxy.example.com/v1", + }); + expect((result.config as any).openaicompatible.apiKey).toBeUndefined(); + }); + it("should set correct base URL for Ollama", async () => { const wizard = new SetupWizard(testWorkspace); setupLocalProviderMocks("ollama", "llama3.2:latest"); diff --git a/tests/onboarding/setupWizardReasoningEffort.test.ts b/tests/onboarding/setupWizardReasoningEffort.test.ts index 6a6921a..28efdb7 100644 --- a/tests/onboarding/setupWizardReasoningEffort.test.ts +++ b/tests/onboarding/setupWizardReasoningEffort.test.ts @@ -160,6 +160,7 @@ const { SetupWizard } = await import("../../src/onboarding/setupWizard"); function setupOpenAIWithReasoningEffort(opts: { model: string; reasoningEffort: string; + customBaseUrl?: string; }) { // showModal calls: language, provider, auth mode, reasoning effort, permissions mockShowModal @@ -172,11 +173,18 @@ function setupOpenAIWithReasoningEffort(opts: { // showPassword: API key mockShowPassword.mockResolvedValueOnce("sk-test-openai-key-long"); - // showInput: model - mockShowInput.mockResolvedValueOnce(opts.model); + if (opts.customBaseUrl) { + mockShowInput + .mockResolvedValueOnce(opts.customBaseUrl) + .mockResolvedValueOnce(opts.model); + } else { + // showInput: model + mockShowInput.mockResolvedValueOnce(opts.model); + } - // showConfirm calls: remember, telemetry, autoReport, prefs, advanced, agents, registration, review + // showConfirm calls: custom base URL, remember, telemetry, autoReport, prefs, advanced, agents, registration, review mockShowConfirm + .mockResolvedValueOnce(Boolean(opts.customBaseUrl)) // customize OpenAI base URL .mockResolvedValueOnce(true) // remember session .mockResolvedValueOnce(true) // telemetry .mockResolvedValueOnce(true) // autoReport @@ -260,6 +268,41 @@ describe("SetupWizard — Reasoning Effort", () => { expect((result.config?.openai as any)?.reasoningEffort).toBe("medium"); }); + it("should persist custom OpenAI base URL for api-key mode while keeping reasoning effort", async () => { + mockShowModal + .mockResolvedValueOnce({ value: "en" }) + .mockResolvedValueOnce({ value: "openai" }) + .mockResolvedValueOnce({ value: "api-key" }) + .mockResolvedValueOnce({ value: "high" }) + .mockResolvedValueOnce({ value: "interactive" }); + + mockShowPassword.mockResolvedValueOnce("sk-test-openai-key-long"); + + mockShowInput + .mockResolvedValueOnce("https://openai-compatible.example.com/v1") + .mockResolvedValueOnce("gpt-5.4"); + + mockShowConfirm + .mockResolvedValueOnce(true) // customize OpenAI base URL + .mockResolvedValueOnce(true) // remember session + .mockResolvedValueOnce(true) // telemetry + .mockResolvedValueOnce(true) // autoReport + .mockResolvedValueOnce(false) // preferences + .mockResolvedValueOnce(false) // advanced + .mockResolvedValueOnce(false) // agents + .mockResolvedValueOnce(false) // registration + .mockResolvedValueOnce(true); // review confirm + + const wizard = new SetupWizard(testWorkspace); + const result = await wizard.run({ skipWelcome: true }); + + expect(result.success).toBe(true); + expect(result.config.openai?.baseUrl).toBe( + "https://openai-compatible.example.com/v1", + ); + expect(result.config.openai?.reasoningEffort).toBe("high"); + }); + it("should NOT prompt reasoning effort for non-OpenAI providers", async () => { setupNonOpenAICloud("openrouter", "your-modelcard-id-here"); @@ -353,6 +396,9 @@ describe("SetupWizard — Reasoning Effort", () => { expect(result.success).toBe(true); expect(mockAuthenticateOpenAIChatGPT).toHaveBeenCalledOnce(); expect(result.config.openai?.authMode).toBe("chatgpt"); + expect(result.config.openai?.baseUrl).toBe( + "https://chatgpt.com/backend-api/codex", + ); expect(result.config.openai?.chatgptAuth?.accountId).toBe( "chatgpt-account-123", ); diff --git a/tests/providers/OpenAIProvider.test.ts b/tests/providers/OpenAIProvider.test.ts index 15305c2..54858d8 100644 --- a/tests/providers/OpenAIProvider.test.ts +++ b/tests/providers/OpenAIProvider.test.ts @@ -156,6 +156,25 @@ describe('OpenAIProvider', () => { provider.complete({ messages: [{ role: 'user', content: 'hi' }], signal: controller.signal }), ).rejects.toMatchObject({ code: 'cancelled' }); }); + + it('throws ApiError when endpoint returns 200 with malformed choices payload', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify({ + id: 'resp-malformed', + created: 123, + choices: [], + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + + await expect(provider.complete({ messages: [{ role: 'user', content: 'hi' }] })) + .rejects.toMatchObject({ + name: 'ApiError', + code: 'unknown', + }); + }); }); describe('message serialization', () => { @@ -335,6 +354,35 @@ describe('OpenAIProvider', () => { }); }); + it('omits Authorization header when api-key mode has no apiKey configured', async () => { + const noKeyProvider = new OpenAIProvider({ + baseUrl: 'http://localhost:9999', + model: 'gpt-4o', + }); + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify({ + id: 'resp-no-key', + created: 123, + choices: [{ + message: { role: 'assistant', content: 'ok' }, + finish_reason: 'stop', + }], + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + + await noKeyProvider.complete({ + messages: [{ role: 'user', content: 'hi' }], + }); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + const requestHeaders = fetchSpy.mock.calls[0]?.[1]?.headers as Record; + expect(requestHeaders.Authorization).toBeUndefined(); + }); + describe('chatgpt auth mode', () => { it('sends chatgpt requests with stream: true to the codex responses backend', async () => { const chatgptProvider = new OpenAIProvider({ diff --git a/tests/providers/ProviderFactory.test.ts b/tests/providers/ProviderFactory.test.ts index 36f0b4c..b60869b 100644 --- a/tests/providers/ProviderFactory.test.ts +++ b/tests/providers/ProviderFactory.test.ts @@ -19,12 +19,13 @@ describe("ProviderFactory", () => { }); describe("getProviderNames()", () => { - it("should always include openrouter, ollama, openai, llamacpp, llmgateway, azure, zai, deepseek, bedrock", () => { + it("should always include openrouter, ollama, openai, openaicompatible, llamacpp, llmgateway, azure, zai, deepseek, bedrock", () => { const providers = ProviderFactory.getProviderNames(); expect(providers).toContain("openrouter"); expect(providers).toContain("ollama"); expect(providers).toContain("openai"); + expect(providers).toContain("openaicompatible"); expect(providers).toContain("llamacpp"); expect(providers).toContain("llmgateway"); expect(providers).toContain("azure"); @@ -53,6 +54,7 @@ describe("ProviderFactory", () => { "nvidia", "openrouter", "openai", + "openaicompatible", "ollama", "llmgateway", "llamacpp", @@ -93,6 +95,21 @@ describe("ProviderFactory", () => { expect(provider.getName()).toBe("openai"); }); + it("should create OpenAIProvider when openaicompatible is configured", () => { + const config = { + provider: "openaicompatible", + openaicompatible: { + apiKey: "test-key", + model: "gpt-4o-mini", + baseUrl: "https://proxy.example.com/v1", + }, + } as unknown as AutohandConfig; + + const provider = ProviderFactory.create(config); + + expect(provider.getName()).toBe("openai"); + }); + it("should create LlamaCppProvider when llamacpp is configured", () => { const config: AutohandConfig = { provider: "llamacpp", @@ -209,6 +226,10 @@ describe("ProviderFactory", () => { expect(ProviderFactory.isValidProvider("openai")).toBe(true); }); + it("should return true for openaicompatible", () => { + expect(ProviderFactory.isValidProvider("openaicompatible")).toBe(true); + }); + it("should return true for llamacpp", () => { expect(ProviderFactory.isValidProvider("llamacpp")).toBe(true); });