Skip to content

Commit 70215f9

Browse files
rowandhAutohand Evolve
andcommitted
Support OpenAI-compatible endpoint configuration with optional API key
Allow endpoint-first setup for OpenAI-compatible providers, keep API keys optional when not required, and harden OpenAI-compatible response parsing to avoid runtime crashes on malformed 200 payloads. Co-authored-by: Autohand Evolve <code-noreply@autohand.ai>
1 parent 23b5539 commit 70215f9

11 files changed

Lines changed: 227 additions & 56 deletions

File tree

docs/config-reference.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -221,19 +221,21 @@ OpenAI-compatible provider configuration for custom gateways/proxies that expose
221221
```json
222222
{
223223
"openaicompatible": {
224-
"apiKey": "your-provider-key",
225224
"baseUrl": "https://your-provider.example.com/v1",
226-
"model": "gpt-4o-mini"
225+
"model": "gpt-4o-mini",
226+
"apiKey": "your-provider-key"
227227
}
228228
}
229229
```
230230

231231
| Field | Type | Required | Default | Description |
232232
| --------- | ------ | -------- | -------------------------- | ------------------------------------------------------------------ |
233-
| `apiKey` | string | Yes | - | API key from your OpenAI-compatible provider |
233+
| `apiKey` | string | No | - | API key for endpoints that require bearer auth |
234234
| `baseUrl` | string | Yes | - | OpenAI-compatible endpoint base URL (for example `.../v1`) |
235235
| `model` | string | Yes | - | Model name supported by your provider |
236236

237+
When `apiKey` is omitted, requests are sent without an `Authorization` header.
238+
237239
### `mlx`
238240

239241
MLX provider for Apple Silicon Macs (local inference).

src/config.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -873,9 +873,18 @@ export function getProviderConfig(
873873
}
874874
} else if (chosen === "openaicompatible") {
875875
const { apiKey, model, baseUrl } = entry as ProviderSettings;
876-
if (!apiKey || apiKey === "replace-me" || !model || !baseUrl) {
876+
if (!model || !baseUrl) {
877877
return null;
878878
}
879+
880+
const sanitizedApiKey =
881+
apiKey && apiKey !== "replace-me" ? apiKey : undefined;
882+
883+
return {
884+
...entry,
885+
...(sanitizedApiKey ? { apiKey: sanitizedApiKey } : {}),
886+
baseUrl,
887+
};
879888
} else if (
880889
chosen === "openrouter" ||
881890
chosen === "llmgateway" ||

src/core/agent/ProviderConfigManager.ts

Lines changed: 56 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -912,19 +912,6 @@ export class ProviderConfigManager {
912912
),
913913
);
914914

915-
const apiKey =
916-
(await showPassword({
917-
title: t("providers.config.enterApiKey", {
918-
provider: t("providers.openaicompatible"),
919-
}),
920-
placeholder: t("ui.apiKeyPlaceholder"),
921-
})) ?? "";
922-
923-
if (!apiKey) {
924-
console.log(chalk.gray("\n" + t("providers.config.cancelled")));
925-
return;
926-
}
927-
928915
const baseUrlInput = await showInput({
929916
title: t("providers.config.changeBaseUrl"),
930917
defaultValue:
@@ -938,6 +925,26 @@ export class ProviderConfigManager {
938925
return;
939926
}
940927

928+
const apiKeyInput = await showPassword({
929+
title: t("providers.config.enterApiKeyOptional", {
930+
provider: t("providers.openaicompatible"),
931+
}),
932+
placeholder: t("ui.apiKeyPlaceholder"),
933+
validate: (val: string) => {
934+
const trimmed = val?.trim();
935+
if (!trimmed) return true;
936+
if (trimmed.length < 10) return t("providers.config.apiKeyTooShort");
937+
return true;
938+
},
939+
});
940+
941+
if (apiKeyInput === undefined || apiKeyInput === null) {
942+
console.log(chalk.gray("\n" + t("providers.config.cancelled")));
943+
return;
944+
}
945+
946+
const apiKey = apiKeyInput.trim();
947+
941948
const model = await showInput({
942949
title: t("providers.config.enterModelId"),
943950
defaultValue:
@@ -955,10 +962,10 @@ export class ProviderConfigManager {
955962
sanitizedModel,
956963
);
957964
this.runtime.config.openaicompatible = {
958-
apiKey,
959965
baseUrl,
960966
model: sanitizedModel,
961967
contextWindow,
968+
...(apiKey ? { apiKey } : {}),
962969
};
963970

964971
this.runtime.config.provider = "openaicompatible";
@@ -2361,19 +2368,26 @@ export class ProviderConfigManager {
23612368
title: t("providers.config.enterApiKey", { provider: providerName }),
23622369
placeholder: t("ui.apiKeyPlaceholder"),
23632370
validate: (val: string) => {
2364-
if (!val?.trim()) return t("providers.config.apiKeyRequired");
2365-
if (val.length < 10) return t("providers.config.apiKeyTooShort");
2371+
const trimmed = val?.trim();
2372+
if (!trimmed) {
2373+
return provider === "openaicompatible"
2374+
? true
2375+
: t("providers.config.apiKeyRequired");
2376+
}
2377+
if (trimmed.length < 10) return t("providers.config.apiKeyTooShort");
23662378
return true;
23672379
},
23682380
});
23692381

2370-
if (!apiKey) {
2382+
if (apiKey === undefined || apiKey === null) {
23712383
console.log(
23722384
chalk.gray("\n" + t("providers.config.settingsChangeCancelled")),
23732385
);
23742386
return;
23752387
}
23762388

2389+
const trimmedApiKey = apiKey.trim();
2390+
23772391
if (provider === "openaicompatible") {
23782392
const baseUrlInput = await showInput({
23792393
title: t("providers.config.changeBaseUrl"),
@@ -2403,26 +2417,32 @@ export class ProviderConfigManager {
24032417
newBaseUrl = baseUrlInput.trim().replace(/\/+$/, "");
24042418
}
24052419

2406-
// Validate the API key
2407-
console.log(chalk.gray("\n" + t("providers.config.validatingApiKey")));
2408-
const validationResult = await this.validateApiKey(
2409-
provider,
2410-
apiKey.trim(),
2411-
provider === "openaicompatible"
2412-
? newBaseUrl
2413-
: provider === "openai"
2414-
? (this.runtime.config.openai?.baseUrl ?? currentSettings?.baseUrl)
2415-
: undefined,
2416-
);
2420+
if (provider === "openaicompatible" && !trimmedApiKey) {
2421+
newApiKey = "";
2422+
} else {
2423+
// Validate the API key
2424+
console.log(chalk.gray("\n" + t("providers.config.validatingApiKey")));
2425+
const validationResult = await this.validateApiKey(
2426+
provider,
2427+
trimmedApiKey,
2428+
provider === "openaicompatible"
2429+
? newBaseUrl
2430+
: provider === "openai"
2431+
? (this.runtime.config.openai?.baseUrl ?? currentSettings?.baseUrl)
2432+
: undefined,
2433+
);
24172434

2418-
if (!validationResult.valid) {
2419-
console.log(chalk.red(`\n✗ ${validationResult.error}`));
2420-
console.log(chalk.gray(validationResult.hint || ""));
2421-
return;
2422-
}
2435+
if (!validationResult.valid) {
2436+
console.log(chalk.red(`\n✗ ${validationResult.error}`));
2437+
console.log(chalk.gray(validationResult.hint || ""));
2438+
return;
2439+
}
24232440

2424-
console.log(chalk.green("✓ " + t("providers.config.apiKeyValid") + "\n"));
2425-
newApiKey = apiKey.trim();
2441+
console.log(
2442+
chalk.green("✓ " + t("providers.config.apiKeyValid") + "\n"),
2443+
);
2444+
newApiKey = trimmedApiKey;
2445+
}
24262446
}
24272447

24282448
// Handle model change
@@ -2665,10 +2685,10 @@ export class ProviderConfigManager {
26652685
};
26662686
} else if (provider === "openaicompatible") {
26672687
this.runtime.config.openaicompatible = {
2668-
apiKey: newApiKey,
26692688
baseUrl,
26702689
model: newModel,
26712690
contextWindow,
2691+
...(newApiKey ? { apiKey: newApiKey } : {}),
26722692
};
26732693
} else if (provider === "openrouter") {
26742694
this.runtime.config.openrouter = {

src/i18n/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -739,6 +739,7 @@
739739
"reasoningEffortLabel": "Reasoning effort: {{level}}",
740740
"enterModelId": "Enter the model ID",
741741
"enterApiKey": "Enter your {{provider}} API key",
742+
"enterApiKeyOptional": "Enter your {{provider}} API key (leave blank if not required)",
742743
"apiKeyUrl": "Get your API key at: {{url}}",
743744
"modelChangeCancelled": "Model change cancelled.",
744745
"modelUnchanged": "Model unchanged.",

src/onboarding/setupWizard.ts

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -217,13 +217,15 @@ export class SetupWizard {
217217
await this.validateApiKeyDuringSetup();
218218
}
219219
} else if (provider === 'openaicompatible') {
220-
const apiKey = await this.promptApiKey(provider);
221-
if (apiKey === null) return this.cancelled();
222-
223220
const baseUrlConfigured = await this.promptOpenAICompatibleBaseUrl();
224221
if (!baseUrlConfigured) return this.cancelled();
225222

226-
await this.validateApiKeyDuringSetup();
223+
const apiKey = await this.promptApiKey(provider, { required: false });
224+
if (apiKey === null) return this.cancelled();
225+
226+
if (apiKey) {
227+
await this.validateApiKeyDuringSetup();
228+
}
227229
} else if (this.requiresApiKey(provider)) {
228230
const apiKey = await this.promptApiKey(provider);
229231
if (apiKey === null) return this.cancelled();
@@ -444,8 +446,12 @@ export class SetupWizard {
444446
/**
445447
* Prompt for API key (cloud providers)
446448
*/
447-
private async promptApiKey(provider: ProviderName): Promise<string | null> {
449+
private async promptApiKey(
450+
provider: ProviderName,
451+
options?: { required?: boolean },
452+
): Promise<string | null> {
448453
this.state.currentStep = 'apiKey';
454+
const required = options?.required !== false;
449455

450456
// Check for existing key
451457
const existingKey = this.getExistingApiKey(provider);
@@ -468,18 +474,22 @@ export class SetupWizard {
468474
title: t('providers.config.enterApiKey', { provider: this.getProviderDisplayName(provider) }),
469475
placeholder: t('ui.apiKeyPlaceholder'),
470476
validate: (val: string) => {
471-
if (!val?.trim()) return t('providers.config.apiKeyRequired');
472-
if (val.length < 10) return t('providers.config.apiKeyTooShort');
477+
const trimmed = val?.trim();
478+
if (!trimmed) {
479+
return required ? t('providers.config.apiKeyRequired') : true;
480+
}
481+
if (trimmed.length < 10) return t('providers.config.apiKeyTooShort');
473482
return true;
474483
}
475484
});
476485

477-
if (!apiKey) {
486+
if (apiKey === undefined || apiKey === null) {
478487
return null;
479488
}
480489

481-
this.state.apiKey = apiKey.trim();
482-
return this.state.apiKey;
490+
const trimmedApiKey = apiKey.trim();
491+
this.state.apiKey = trimmedApiKey || undefined;
492+
return trimmedApiKey;
483493
}
484494

485495
private async promptOpenAIAuthMode(): Promise<OpenAIAuthMode | null> {
@@ -1134,9 +1144,9 @@ export class SetupWizard {
11341144
};
11351145
} else if (this.state.provider === 'openaicompatible') {
11361146
config.openaicompatible = {
1137-
apiKey: this.state.apiKey ?? '',
11381147
model: this.state.model ?? this.getDefaultModel('openaicompatible'),
11391148
baseUrl: this.state.providerBaseUrl ?? this.getDefaultBaseUrl('openaicompatible'),
1149+
...(this.state.apiKey ? { apiKey: this.state.apiKey } : {}),
11401150
};
11411151
} else if (this.state.provider === 'vertexai' && this.state.vertexaiConfig) {
11421152
config.vertexai = this.state.vertexaiConfig;
@@ -2114,7 +2124,7 @@ export class SetupWizard {
21142124
// Helper methods
21152125

21162126
private requiresApiKey(provider: ProviderName): boolean {
2117-
return provider === 'openrouter' || provider === 'openaicompatible' || provider === 'llmgateway' || provider === 'zai' || provider === 'vertexai' || provider === 'xai' || provider === 'cerebras' || provider === 'nvidia' || provider === 'deepseek';
2127+
return provider === 'openrouter' || provider === 'llmgateway' || provider === 'zai' || provider === 'vertexai' || provider === 'xai' || provider === 'cerebras' || provider === 'nvidia' || provider === 'deepseek';
21182128
}
21192129

21202130
private getProviderDisplayName(provider: ProviderName): string {

src/providers/OpenAIProvider.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -310,8 +310,18 @@ export class OpenAIProvider implements LLMProvider {
310310
}
311311

312312
const data = await response.json() as OpenAIChatResponse;
313-
const message = data.choices[0].message;
314-
const finishReason = data.choices[0].finish_reason;
313+
const firstChoice = data.choices?.[0];
314+
if (!firstChoice?.message) {
315+
throw new ApiError(
316+
'Received an invalid response from the OpenAI-compatible endpoint. Expected choices[0].message.',
317+
'unknown',
318+
response.status,
319+
false,
320+
);
321+
}
322+
323+
const message = firstChoice.message;
324+
const finishReason = firstChoice.finish_reason;
315325

316326
// Parse tool calls if present
317327
let toolCalls: LLMToolCall[] | undefined;
@@ -647,6 +657,10 @@ export class OpenAIProvider implements LLMProvider {
647657
};
648658
}
649659

660+
if (!this.apiKey.trim()) {
661+
return {};
662+
}
663+
650664
return {
651665
Authorization: `Bearer ${this.apiKey}`,
652666
};

src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export interface OpenAISettings extends ProviderSettings {
7575
}
7676

7777
export interface OpenAICompatibleSettings extends ProviderSettings {
78-
apiKey: string;
78+
apiKey?: string;
7979
baseUrl: string;
8080
}
8181

tests/configProviders.spec.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ describe('getProviderConfig', () => {
116116
expect(result).toBeNull();
117117
});
118118

119-
it('returns openaicompatible settings only when api key, model, and base url are configured', () => {
119+
it('returns openaicompatible settings when model and base url are configured', () => {
120120
const cfg = {
121121
provider: 'openaicompatible',
122122
openaicompatible: {
@@ -133,6 +133,22 @@ describe('getProviderConfig', () => {
133133
expect(result!.baseUrl).toBe('https://proxy.example.com/v1');
134134
});
135135

136+
it('returns openaicompatible settings when api key is omitted', () => {
137+
const cfg = {
138+
provider: 'openaicompatible',
139+
openaicompatible: {
140+
model: 'gpt-4o-mini',
141+
baseUrl: 'https://proxy.example.com/v1'
142+
}
143+
} as unknown as AutohandConfig;
144+
145+
const result = getProviderConfig(cfg, 'openaicompatible' as any);
146+
expect(result).not.toBeNull();
147+
expect(result!.apiKey).toBeUndefined();
148+
expect(result!.model).toBe('gpt-4o-mini');
149+
expect(result!.baseUrl).toBe('https://proxy.example.com/v1');
150+
});
151+
136152
it('returns null for openaicompatible when base url is missing', () => {
137153
const cfg = {
138154
provider: 'openaicompatible',

tests/core/agent/ProviderConfigManager.openai.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,23 @@ describe("ProviderConfigManager openai auth mode", () => {
181181
expect(mockSaveConfig).toHaveBeenCalledOnce();
182182
});
183183

184+
it("configures openaicompatible with endpoint and model when API key is omitted", async () => {
185+
mockShowPassword.mockResolvedValueOnce("");
186+
mockShowInput
187+
.mockResolvedValueOnce("https://proxy.example.com/v1")
188+
.mockResolvedValueOnce("gpt-4o-mini");
189+
190+
await (manager as any).configureOpenAICompatible();
191+
192+
expect(runtime.config.provider).toBe("openaicompatible");
193+
expect(runtime.config.openaicompatible).toMatchObject({
194+
baseUrl: "https://proxy.example.com/v1",
195+
model: "gpt-4o-mini",
196+
});
197+
expect(runtime.config.openaicompatible?.apiKey).toBeUndefined();
198+
expect(mockSaveConfig).toHaveBeenCalledOnce();
199+
});
200+
184201
it("validates openaicompatible api key against the newly entered base URL", async () => {
185202
runtime.config.provider = "openaicompatible";
186203
runtime.config.openaicompatible = {

0 commit comments

Comments
 (0)