diff --git a/.env.example b/.env.example index 695d77c6..33d0167a 100644 --- a/.env.example +++ b/.env.example @@ -157,6 +157,15 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here # Use a custom OpenAI-compatible endpoint (optional — defaults to api.openai.com) # OPENAI_BASE_URL=https://api.openai.com/v1 +# Choose the OpenAI-compatible API surface (optional — defaults to chat_completions) +# Supported: chat_completions, responses +# OPENAI_API_FORMAT=chat_completions +# Choose a custom auth header for OpenAI-compatible providers (optional). +# Authorization defaults to Bearer; custom headers default to the raw API key. +# Set OPENAI_AUTH_HEADER_VALUE when the header value differs from OPENAI_API_KEY. +# OPENAI_AUTH_HEADER=api-key +# OPENAI_AUTH_SCHEME=raw +# OPENAI_AUTH_HEADER_VALUE=your-header-value-here # Fallback context window size (tokens) when the model is not found in the # built-in table (default: 128000). Increase this for models with larger diff --git a/src/components/ProviderManager.test.tsx b/src/components/ProviderManager.test.tsx index 8dddf80e..6abf2ce3 100644 --- a/src/components/ProviderManager.test.tsx +++ b/src/components/ProviderManager.test.tsx @@ -1061,25 +1061,43 @@ test('ProviderManager editing an active multi-model provider keeps app state on mounted.getOutput, frame => frame.includes('Edit provider profile') && - frame.includes('Step 1 of 4'), + frame.includes('Step 1 of 7'), ) mounted.stdin.write('\r') await waitForFrameOutput( mounted.getOutput, - frame => frame.includes('Step 2 of 4'), + frame => frame.includes('Step 2 of 7'), ) mounted.stdin.write('\r') await waitForFrameOutput( mounted.getOutput, - frame => frame.includes('Step 3 of 4'), + frame => frame.includes('Step 3 of 7'), ) mounted.stdin.write('\r') await waitForFrameOutput( mounted.getOutput, - frame => frame.includes('Step 4 of 4'), + frame => frame.includes('Step 4 of 7'), + ) + + mounted.stdin.write('\r') + await waitForFrameOutput( + mounted.getOutput, + frame => frame.includes('Step 5 of 7'), + ) + + mounted.stdin.write('\r') + await waitForFrameOutput( + mounted.getOutput, + frame => frame.includes('Step 6 of 7'), + ) + + mounted.stdin.write('\r') + await waitForFrameOutput( + mounted.getOutput, + frame => frame.includes('Step 7 of 7'), ) mounted.stdin.write('\r') diff --git a/src/components/ProviderManager.tsx b/src/components/ProviderManager.tsx index 73c1bcde..6ecd66ab 100644 --- a/src/components/ProviderManager.tsx +++ b/src/components/ProviderManager.tsx @@ -81,7 +81,14 @@ type Screen = | 'select-edit' | 'select-delete' -type DraftField = 'name' | 'baseUrl' | 'model' | 'apiKey' +type DraftField = + | 'name' + | 'baseUrl' + | 'model' + | 'apiKey' + | 'apiFormat' + | 'authHeader' + | 'authHeaderValue' type ProviderDraft = Record @@ -130,6 +137,27 @@ const FORM_STEPS: Array<{ placeholder: 'e.g. llama3.1:8b or glm-4.7; glm-4.7-flash', helpText: 'Model name(s) to use. Separate multiple with ";" or ","; first is default.', }, + { + key: 'apiFormat', + label: 'API mode', + placeholder: 'chat_completions', + helpText: 'Choose the OpenAI-compatible API surface for this provider.', + optional: true, + }, + { + key: 'authHeader', + label: 'Auth header', + placeholder: 'e.g. api-key or X-API-Key', + helpText: 'Optional. Header name used for a custom provider key.', + optional: true, + }, + { + key: 'authHeaderValue', + label: 'Auth header value', + placeholder: 'Leave empty to use the API key value', + helpText: 'Optional. Value sent in the custom auth header.', + optional: true, + }, { key: 'apiKey', label: 'API key', @@ -154,6 +182,9 @@ function toDraft(profile: ProviderProfile): ProviderDraft { baseUrl: profile.baseUrl, model: profile.model, apiKey: profile.apiKey ?? '', + apiFormat: profile.apiFormat ?? 'chat_completions', + authHeader: profile.authHeader ?? '', + authHeaderValue: profile.authHeaderValue ?? '', } } @@ -164,6 +195,9 @@ function presetToDraft(preset: ProviderPreset): ProviderDraft { baseUrl: defaults.baseUrl, model: defaults.model, apiKey: defaults.apiKey ?? '', + apiFormat: 'chat_completions', + authHeader: '', + authHeaderValue: '', } } @@ -177,7 +211,15 @@ function profileSummary(profile: ProviderProfile, isActive: boolean): string { models.length <= 3 ? models.join(', ') : `${models[0]}, ${models[1]} + ${models.length - 2} more` - return `${providerKind} · ${profile.baseUrl} · ${modelDisplay} · ${keyInfo}${activeSuffix}` + const modeInfo = + profile.provider === 'openai' + ? ` · ${profile.apiFormat === 'responses' ? 'responses' : 'chat/completions'}` + : '' + const authInfo = + profile.provider === 'openai' && profile.authHeader + ? ` · ${profile.authHeader} auth` + : '' + return `${providerKind} · ${profile.baseUrl} · ${modelDisplay}${modeInfo}${authInfo} · ${keyInfo}${activeSuffix}` } function getGithubCredentialSourceFromEnv( @@ -456,7 +498,18 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { }) }, []) - const currentStep = FORM_STEPS[formStepIndex] ?? FORM_STEPS[0] + const formSteps = React.useMemo( + () => + draftProvider === 'openai' + ? FORM_STEPS + : FORM_STEPS.filter(step => + step.key !== 'apiFormat' && + step.key !== 'authHeader' && + step.key !== 'authHeaderValue' + ), + [draftProvider], + ) + const currentStep = formSteps[formStepIndex] ?? formSteps[0] ?? FORM_STEPS[0] const currentStepKey = currentStep.key const currentValue = draft[currentStepKey] @@ -940,6 +993,9 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { baseUrl: defaults.baseUrl, model: defaults.model, apiKey: defaults.apiKey ?? '', + apiFormat: 'chat_completions', + authHeader: '', + authHeaderValue: '', } setEditingProfileId(null) setDraftProvider(defaults.provider ?? 'openai') @@ -986,6 +1042,22 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { baseUrl: nextDraft.baseUrl, model: nextDraft.model, apiKey: nextDraft.apiKey, + apiFormat: + draftProvider === 'openai' && nextDraft.apiFormat === 'responses' + ? 'responses' + : 'chat_completions', + authHeader: + draftProvider === 'openai' && nextDraft.authHeader + ? nextDraft.authHeader + : undefined, + authScheme: + draftProvider === 'openai' && nextDraft.authHeader + ? (nextDraft.authHeader.toLowerCase() === 'authorization' ? 'bearer' : 'raw') + : undefined, + authHeaderValue: + draftProvider === 'openai' && nextDraft.authHeaderValue + ? nextDraft.authHeaderValue + : undefined, } const saved = editingProfileId @@ -1208,9 +1280,9 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { setDraft(nextDraft) setErrorMessage(undefined) - if (formStepIndex < FORM_STEPS.length - 1) { + if (formStepIndex < formSteps.length - 1) { const nextIndex = formStepIndex + 1 - const nextKey = FORM_STEPS[nextIndex]?.key ?? 'name' + const nextKey = formSteps[nextIndex]?.key ?? 'name' setFormStepIndex(nextIndex) setCursorOffset(nextDraft[nextKey].length) return @@ -1224,7 +1296,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { if (formStepIndex > 0) { const nextIndex = formStepIndex - 1 - const nextKey = FORM_STEPS[nextIndex]?.key ?? 'name' + const nextKey = formSteps[nextIndex]?.key ?? 'name' setFormStepIndex(nextIndex) setCursorOffset(draft[nextKey].length) return @@ -1424,28 +1496,59 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { : 'OpenAI-compatible API'} - Step {formStepIndex + 1} of {FORM_STEPS.length}: {currentStep.label} + Step {formStepIndex + 1} of {formSteps.length}: {currentStep.label} - - {figures.pointer} - - setDraft(prev => ({ - ...prev, - [currentStepKey]: value, - })) + {currentStepKey === 'apiFormat' ? ( +