diff --git a/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.client.test.tsx b/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.client.test.tsx index 937606842..d606ee66a 100644 --- a/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.client.test.tsx @@ -249,6 +249,22 @@ function ollamaProviderStatus(): SetupModelStatus['providers'][number] { }; } +function openAiCompatibleProviderStatus(): SetupModelStatus['providers'][number] { + return { + id: 'openai-compatible', + label: 'OpenAI-compatible', + envVarName: 'OPENAI_COMPATIBLE_BASE_URL', + envVarLabel: 'Endpoint URL', + defaultRoomoteModel: '', + authKind: 'endpoint', + suggestedTaskModels: [], + additionalEnvFields: [], + additionalEnvValues: {}, + runtimeApiKeySatisfied: false, + savedApiKeySatisfied: false, + }; +} + function buildModelSetup( overrides: Partial = {}, ): SetupModelStatus { @@ -500,6 +516,54 @@ describe('StepInferenceProvider configured API key display', () => { expect(mutateAsyncMock).toHaveBeenCalledTimes(1); }); + it('keeps setup incomplete when OpenAI-compatible discovery returns HTTP 400 with no models', async () => { + mutateAsyncMock.mockResolvedValueOnce({ + error: 'OpenAI-compatible returned HTTP 400.', + modelCount: 0, + recommendedModels: [], + }); + const onContinue = vi.fn(); + + render( + , + ); + + selectProvider('openai-compatible'); + fireEvent.change( + screen.getByPlaceholderText(/Endpoint URL for OpenAI-compatible/i), + { target: { value: 'https://proxy.example.com/v1' } }, + ); + fireEvent.change( + screen.getByRole('textbox', { + name: 'Connection name for OpenAI-compatible endpoint', + }), + { target: { value: 'company-proxy' } }, + ); + fireEvent.click(screen.getByRole('button', { name: /continue/i })); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith( + 'OpenAI-compatible returned HTTP 400.', + ); + }); + expect(mutateAsyncMock).toHaveBeenCalledOnce(); + expect(mutateAsyncMock).toHaveBeenCalledWith({ + provider: 'openai-compatible', + baseUrl: 'https://proxy.example.com/v1', + apiKey: undefined, + }); + expect(onContinue).not.toHaveBeenCalled(); + }); + it('reports a tool-calling failure separately from model eligibility', async () => { mutateAsyncMock .mockResolvedValueOnce({ diff --git a/apps/web/src/trpc/commands/task-models/local-provider-discovery.test.ts b/apps/web/src/trpc/commands/task-models/local-provider-discovery.test.ts index 090b5d0a3..ba65ef897 100644 --- a/apps/web/src/trpc/commands/task-models/local-provider-discovery.test.ts +++ b/apps/web/src/trpc/commands/task-models/local-provider-discovery.test.ts @@ -166,6 +166,34 @@ describe('discoverProviderModels', () => { ); }); + it('returns no models when an OpenAI-compatible endpoint responds with HTTP 400', async () => { + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ error: 'No models available' }), { + status: 400, + headers: { 'content-type': 'application/json' }, + }), + ); + + await expect( + discoverProviderModels({ + provider: 'openai-compatible', + baseUrl: 'https://proxy.example.com/v1', + apiKey: 'submitted-key', + }), + ).resolves.toEqual({ + models: [], + modelCount: 0, + recommendedModels: [], + error: 'OpenAI-compatible returned HTTP 400.', + }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://proxy.example.com/v1/models', + expect.objectContaining({ + headers: { Authorization: 'Bearer submitted-key' }, + }), + ); + }); + it('uses saved LiteLLM credentials and metadata when discovering models', async () => { mockGetPersistedEnvironmentVariableValues.mockResolvedValue({ LITELLM_BASE_URL: 'https://litellm.example/v1',